opencandle 0.10.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 (223) hide show
  1. package/README.md +12 -9
  2. package/dist/analysts/orchestrator.d.ts +3 -4
  3. package/dist/analysts/orchestrator.js +34 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/doctor/report.js +1 -1
  11. package/dist/doctor/report.js.map +1 -1
  12. package/dist/infra/cache.d.ts +6 -0
  13. package/dist/infra/cache.js +16 -6
  14. package/dist/infra/cache.js.map +1 -1
  15. package/dist/infra/freshness.d.ts +21 -0
  16. package/dist/infra/freshness.js +119 -0
  17. package/dist/infra/freshness.js.map +1 -0
  18. package/dist/infra/market-calendar.d.ts +14 -0
  19. package/dist/infra/market-calendar.js +137 -0
  20. package/dist/infra/market-calendar.js.map +1 -0
  21. package/dist/infra/rate-limiter.js +1 -0
  22. package/dist/infra/rate-limiter.js.map +1 -1
  23. package/dist/market-state/daily-report.d.ts +1 -1
  24. package/dist/market-state/daily-report.js +5 -16
  25. package/dist/market-state/daily-report.js.map +1 -1
  26. package/dist/market-state/service.d.ts +0 -35
  27. package/dist/market-state/service.js +0 -63
  28. package/dist/market-state/service.js.map +1 -1
  29. package/dist/market-state/summaries.d.ts +8 -0
  30. package/dist/market-state/summaries.js +70 -0
  31. package/dist/market-state/summaries.js.map +1 -0
  32. package/dist/memory/sqlite.js +20 -19
  33. package/dist/memory/sqlite.js.map +1 -1
  34. package/dist/onboarding/providers.d.ts +25 -1
  35. package/dist/onboarding/providers.js +26 -0
  36. package/dist/onboarding/providers.js.map +1 -1
  37. package/dist/pi/opencandle-extension.js +57 -226
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-action-dedupe.d.ts +6 -0
  40. package/dist/pi/session-action-dedupe.js +126 -0
  41. package/dist/pi/session-action-dedupe.js.map +1 -0
  42. package/dist/pi/session-writer-lock.d.ts +26 -2
  43. package/dist/pi/session-writer-lock.js +230 -18
  44. package/dist/pi/session-writer-lock.js.map +1 -1
  45. package/dist/pi/setup.js +5 -5
  46. package/dist/pi/setup.js.map +1 -1
  47. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  48. package/dist/pi/tui-session-coordinator.js +283 -0
  49. package/dist/pi/tui-session-coordinator.js.map +1 -0
  50. package/dist/prompts/context-builder.js +1 -1
  51. package/dist/prompts/policy-cards.js +1 -1
  52. package/dist/prompts/policy-cards.js.map +1 -1
  53. package/dist/providers/alpha-vantage.js +6 -0
  54. package/dist/providers/alpha-vantage.js.map +1 -1
  55. package/dist/providers/coingecko.js +1 -0
  56. package/dist/providers/coingecko.js.map +1 -1
  57. package/dist/providers/polymarket.d.ts +2 -0
  58. package/dist/providers/polymarket.js +163 -0
  59. package/dist/providers/polymarket.js.map +1 -0
  60. package/dist/providers/wrap-provider.js +5 -3
  61. package/dist/providers/wrap-provider.js.map +1 -1
  62. package/dist/providers/yahoo-finance.d.ts +3 -0
  63. package/dist/providers/yahoo-finance.js +206 -10
  64. package/dist/providers/yahoo-finance.js.map +1 -1
  65. package/dist/routing/classify-intent.js +4 -5
  66. package/dist/routing/classify-intent.js.map +1 -1
  67. package/dist/routing/entity-extractor.js +16 -0
  68. package/dist/routing/entity-extractor.js.map +1 -1
  69. package/dist/routing/route-manifest.js +1 -2
  70. package/dist/routing/route-manifest.js.map +1 -1
  71. package/dist/routing/router-llm-client.js +18 -3
  72. package/dist/routing/router-llm-client.js.map +1 -1
  73. package/dist/routing/router-prompt.js +1 -1
  74. package/dist/routing/router.d.ts +1 -1
  75. package/dist/routing/router.js +309 -9
  76. package/dist/routing/router.js.map +1 -1
  77. package/dist/routing/slot-resolver.d.ts +1 -0
  78. package/dist/routing/slot-resolver.js +1 -1
  79. package/dist/routing/slot-resolver.js.map +1 -1
  80. package/dist/runtime/evidence.d.ts +1 -0
  81. package/dist/runtime/evidence.js.map +1 -1
  82. package/dist/runtime/numeric-claims.d.ts +23 -0
  83. package/dist/runtime/numeric-claims.js +100 -0
  84. package/dist/runtime/numeric-claims.js.map +1 -0
  85. package/dist/runtime/planning-evidence.js +1 -76
  86. package/dist/runtime/planning-evidence.js.map +1 -1
  87. package/dist/runtime/prompt-step.d.ts +10 -1
  88. package/dist/runtime/prompt-step.js +116 -2
  89. package/dist/runtime/prompt-step.js.map +1 -1
  90. package/dist/runtime/session-coordinator.d.ts +11 -1
  91. package/dist/runtime/session-coordinator.js +354 -62
  92. package/dist/runtime/session-coordinator.js.map +1 -1
  93. package/dist/runtime/validation.d.ts +3 -1
  94. package/dist/runtime/workflow-events.d.ts +1 -1
  95. package/dist/runtime/workflow-events.js.map +1 -1
  96. package/dist/runtime/workflow-types.d.ts +3 -0
  97. package/dist/runtime/workflow-types.js.map +1 -1
  98. package/dist/system-prompt.js +1 -1
  99. package/dist/tools/fundamentals/comps.d.ts +1 -0
  100. package/dist/tools/fundamentals/comps.js +20 -9
  101. package/dist/tools/fundamentals/comps.js.map +1 -1
  102. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  103. package/dist/tools/fundamentals/dcf.js +190 -76
  104. package/dist/tools/fundamentals/dcf.js.map +1 -1
  105. package/dist/tools/index.d.ts +1 -1
  106. package/dist/tools/index.js +3 -3
  107. package/dist/tools/index.js.map +1 -1
  108. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  109. package/dist/tools/macro/event-probabilities.js +121 -0
  110. package/dist/tools/macro/event-probabilities.js.map +1 -0
  111. package/dist/tools/market/crypto-price.d.ts +4 -1
  112. package/dist/tools/market/crypto-price.js +10 -1
  113. package/dist/tools/market/crypto-price.js.map +1 -1
  114. package/dist/tools/market/screen-stocks.js +14 -3
  115. package/dist/tools/market/screen-stocks.js.map +1 -1
  116. package/dist/tools/market/stock-quote.d.ts +4 -1
  117. package/dist/tools/market/stock-quote.js +9 -4
  118. package/dist/tools/market/stock-quote.js.map +1 -1
  119. package/dist/tools/options/option-chain.d.ts +4 -1
  120. package/dist/tools/options/option-chain.js +12 -1
  121. package/dist/tools/options/option-chain.js.map +1 -1
  122. package/dist/tools/portfolio/daily-report.js +10 -4
  123. package/dist/tools/portfolio/daily-report.js.map +1 -1
  124. package/dist/tools/portfolio/risk-analysis.js +1 -1
  125. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  126. package/dist/tools/technical/backtest.d.ts +23 -5
  127. package/dist/tools/technical/backtest.js +131 -94
  128. package/dist/tools/technical/backtest.js.map +1 -1
  129. package/dist/tools/technical/indicators.js +1 -1
  130. package/dist/tools/technical/indicators.js.map +1 -1
  131. package/dist/types/fundamentals.d.ts +1 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/market.d.ts +2 -0
  134. package/dist/types/options.d.ts +1 -0
  135. package/dist/types/prediction-markets.d.ts +13 -0
  136. package/dist/types/prediction-markets.js +2 -0
  137. package/dist/types/prediction-markets.js.map +1 -0
  138. package/gui/server/chat-event-adapter.ts +89 -5
  139. package/gui/server/http-routes.ts +661 -37
  140. package/gui/server/invoke-tool.ts +165 -16
  141. package/gui/server/live-chat-event-adapter.ts +37 -2
  142. package/gui/server/local-session-coordinator.ts +97 -0
  143. package/gui/server/market-state-api.ts +64 -48
  144. package/gui/server/projector.ts +132 -3
  145. package/gui/server/server.ts +51 -6
  146. package/gui/server/session-actions.ts +146 -45
  147. package/gui/server/session-entry-wait.ts +22 -3
  148. package/gui/server/tool-metadata.ts +3 -1
  149. package/gui/server/ws-hub.ts +77 -10
  150. package/gui/shared/chat-events.ts +10 -1
  151. package/gui/shared/event-reducer.ts +1 -0
  152. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  153. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  154. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  155. package/gui/web/dist/index.html +2 -2
  156. package/package.json +13 -15
  157. package/src/analysts/orchestrator.ts +35 -56
  158. package/src/cli-main.ts +253 -13
  159. package/src/config.ts +12 -20
  160. package/src/doctor/report.ts +1 -1
  161. package/src/infra/cache.ts +24 -11
  162. package/src/infra/freshness.ts +165 -0
  163. package/src/infra/market-calendar.ts +193 -0
  164. package/src/infra/rate-limiter.ts +1 -0
  165. package/src/market-state/daily-report.ts +6 -16
  166. package/src/market-state/service.ts +0 -136
  167. package/src/market-state/summaries.ts +75 -0
  168. package/src/memory/sqlite.ts +23 -19
  169. package/src/onboarding/providers.ts +29 -1
  170. package/src/pi/opencandle-extension.ts +67 -270
  171. package/src/pi/session-action-dedupe.ts +155 -0
  172. package/src/pi/session-writer-lock.ts +290 -20
  173. package/src/pi/setup.ts +6 -6
  174. package/src/pi/tui-session-coordinator.ts +351 -0
  175. package/src/prompts/context-builder.ts +1 -1
  176. package/src/prompts/policy-cards.ts +1 -1
  177. package/src/providers/alpha-vantage.ts +6 -0
  178. package/src/providers/coingecko.ts +2 -0
  179. package/src/providers/polymarket.ts +214 -0
  180. package/src/providers/wrap-provider.ts +5 -3
  181. package/src/providers/yahoo-finance.ts +339 -27
  182. package/src/routing/classify-intent.ts +4 -5
  183. package/src/routing/entity-extractor.ts +17 -0
  184. package/src/routing/route-manifest.ts +1 -2
  185. package/src/routing/router-llm-client.ts +31 -19
  186. package/src/routing/router-prompt.ts +1 -1
  187. package/src/routing/router.ts +379 -8
  188. package/src/routing/slot-resolver.ts +1 -1
  189. package/src/runtime/evidence.ts +1 -0
  190. package/src/runtime/numeric-claims.ts +108 -0
  191. package/src/runtime/planning-evidence.ts +6 -91
  192. package/src/runtime/prompt-step.ts +127 -2
  193. package/src/runtime/session-coordinator.ts +464 -74
  194. package/src/runtime/validation.ts +1 -1
  195. package/src/runtime/workflow-events.ts +2 -0
  196. package/src/runtime/workflow-types.ts +3 -0
  197. package/src/system-prompt.ts +1 -1
  198. package/src/tools/AGENTS.md +1 -1
  199. package/src/tools/fundamentals/comps.ts +25 -14
  200. package/src/tools/fundamentals/dcf.ts +217 -87
  201. package/src/tools/index.ts +3 -3
  202. package/src/tools/macro/event-probabilities.ts +141 -0
  203. package/src/tools/market/crypto-price.ts +14 -2
  204. package/src/tools/market/screen-stocks.ts +14 -3
  205. package/src/tools/market/stock-quote.ts +49 -43
  206. package/src/tools/options/option-chain.ts +16 -2
  207. package/src/tools/portfolio/daily-report.ts +10 -4
  208. package/src/tools/portfolio/risk-analysis.ts +1 -1
  209. package/src/tools/technical/backtest.ts +167 -108
  210. package/src/tools/technical/indicators.ts +1 -1
  211. package/src/types/fundamentals.ts +1 -0
  212. package/src/types/index.ts +1 -0
  213. package/src/types/market.ts +2 -0
  214. package/src/types/options.ts +1 -0
  215. package/src/types/prediction-markets.ts +13 -0
  216. package/dist/tools/portfolio/predictions.d.ts +0 -55
  217. package/dist/tools/portfolio/predictions.js +0 -422
  218. package/dist/tools/portfolio/predictions.js.map +0 -1
  219. package/gui/server/chat-run-session.ts +0 -16
  220. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  221. package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
  222. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  223. package/src/tools/portfolio/predictions.ts +0 -553
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
+ import { buildFreshnessStamp, type FreshnessStamp, formatAsOfLine } from "../../infra/freshness.js";
3
4
  import { getCryptoPrice } from "../../providers/coingecko.js";
4
5
  import { wrapProvider } from "../../providers/wrap-provider.js";
5
6
  import type { CryptoPrice } from "../../types/market.js";
@@ -10,7 +11,10 @@ const params = Type.Object({
10
11
  }),
11
12
  });
12
13
 
13
- export const cryptoPriceTool: AgentTool<typeof params, CryptoPrice> = {
14
+ export const cryptoPriceTool: AgentTool<
15
+ typeof params,
16
+ CryptoPrice & { freshness: FreshnessStamp }
17
+ > = {
14
18
  name: "get_crypto_price",
15
19
  label: "Crypto Price",
16
20
  description: "Get current crypto price, 24h change, market cap, volume, ATH, and supply data",
@@ -26,6 +30,13 @@ export const cryptoPriceTool: AgentTool<typeof params, CryptoPrice> = {
26
30
  };
27
31
  }
28
32
  const crypto = result.data;
33
+ const freshness = buildFreshnessStamp({
34
+ asOf: crypto.asOf,
35
+ cached: result.cached,
36
+ stale: result.stale,
37
+ cachedAt: result.cached || result.stale ? result.timestamp : undefined,
38
+ assetClass: "crypto",
39
+ });
29
40
  const sign = crypto.changePercent24h >= 0 ? "+" : "";
30
41
  const text = [
31
42
  `${crypto.name} (${crypto.symbol.toUpperCase()}): $${formatPrice(crypto.price)} (${sign}${crypto.changePercent24h.toFixed(2)}%)`,
@@ -33,9 +44,10 @@ export const cryptoPriceTool: AgentTool<typeof params, CryptoPrice> = {
33
44
  `Market Cap: $${formatLargeNumber(crypto.marketCap)} | 24h Volume: $${formatLargeNumber(crypto.volume24h)}`,
34
45
  `ATH: $${formatPrice(crypto.ath)} (${crypto.athDate.split("T")[0]})`,
35
46
  `Circulating: ${formatLargeNumber(crypto.circulatingSupply)} ${crypto.symbol.toUpperCase()}`,
47
+ formatAsOfLine(freshness),
36
48
  ].join("\n");
37
49
 
38
- return { content: [{ type: "text", text }], details: crypto };
50
+ return { content: [{ type: "text", text }], details: { ...crypto, freshness } };
39
51
  },
40
52
  };
41
53
 
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { type Static, Type } from "@sinclair/typebox";
3
+ import { buildFreshnessStamp, formatAsOfLine } from "../../infra/freshness.js";
3
4
  import type { ScreenerRow, ScreenFilterOp } from "../../providers/tradingview.js";
4
5
  import { screenStocks } from "../../providers/tradingview.js";
5
6
  import { wrapProvider } from "../../providers/wrap-provider.js";
@@ -136,12 +137,21 @@ export const screenStocksTool: AgentTool<typeof params, ScreenerRow[]> = {
136
137
  }
137
138
 
138
139
  const rows = result.data;
140
+ const freshness = buildFreshnessStamp({
141
+ cached: result.cached,
142
+ stale: result.stale,
143
+ cachedAt: result.cached || result.stale ? result.timestamp : undefined,
144
+ dataDelayMs: 15 * 60_000,
145
+ });
139
146
  if (rows.length === 0) {
140
147
  return {
141
148
  content: [
142
149
  {
143
150
  type: "text",
144
- text: "No stocks matched the screen. TradingView scanner data may be delayed about 15 minutes and comes from an unofficial endpoint.",
151
+ text: [
152
+ "No stocks matched the screen. TradingView scanner data may be delayed about 15 minutes and comes from an unofficial endpoint.",
153
+ formatAsOfLine(freshness),
154
+ ].join("\n"),
145
155
  },
146
156
  ],
147
157
  details: rows,
@@ -150,11 +160,12 @@ export const screenStocksTool: AgentTool<typeof params, ScreenerRow[]> = {
150
160
 
151
161
  const lines = [
152
162
  `**Stock screen** — ${rows.length} TradingView result${rows.length === 1 ? "" : "s"}`,
153
- ...(result.stale ? [`⚠ Using cached TradingView screen from ${result.timestamp}.`] : []),
154
- `Data freshness: ${result.stale ? `cached screen retrieved at ${result.timestamp}` : `retrieved at ${result.timestamp}`}; TradingView scanner data may be delayed about 15 minutes and comes from an unofficial endpoint.`,
163
+ rows[0]?.dataCaveat ??
164
+ "TradingView scanner data may be delayed about 15 minutes and comes from an unofficial endpoint.",
155
165
  formatInterpretationNote(normalized),
156
166
  "",
157
167
  ...rows.map(formatRow),
168
+ formatAsOfLine(freshness),
158
169
  ];
159
170
 
160
171
  return {
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import { getConfig } from "../../config.js";
4
+ import { buildFreshnessStamp, type FreshnessStamp, formatAsOfLine } from "../../infra/freshness.js";
4
5
  import { getGlobalQuote } from "../../providers/alpha-vantage.js";
5
6
  import { withFallback } from "../../providers/with-fallback.js";
6
7
  import { getQuote } from "../../providers/yahoo-finance.js";
@@ -10,54 +11,59 @@ const params = Type.Object({
10
11
  symbol: Type.String({ description: "Stock ticker symbol (e.g. AAPL, MSFT, TSLA)" }),
11
12
  });
12
13
 
13
- export const stockQuoteTool: AgentTool<typeof params, StockQuote> = {
14
- name: "get_stock_quote",
15
- label: "Stock Quote",
16
- description:
17
- "Get real-time stock price, volume, market cap, and 52-week range for a ticker symbol",
18
- parameters: params,
19
- async execute(_toolCallId, args) {
20
- const symbol = args.symbol.toUpperCase();
21
- const apiKey = getConfig().alphaVantageApiKey;
14
+ export const stockQuoteTool: AgentTool<typeof params, StockQuote & { freshness: FreshnessStamp }> =
15
+ {
16
+ name: "get_stock_quote",
17
+ label: "Stock Quote",
18
+ description:
19
+ "Get real-time stock price, volume, market cap, and 52-week range for a ticker symbol",
20
+ parameters: params,
21
+ async execute(_toolCallId, args) {
22
+ const symbol = args.symbol.toUpperCase();
23
+ const apiKey = getConfig().alphaVantageApiKey;
22
24
 
23
- const entries = [
24
- { provider: "yahoo" as const, fn: () => getQuote(symbol) },
25
- ...(apiKey
26
- ? [{ provider: "alphavantage" as const, fn: () => getGlobalQuote(symbol, apiKey) }]
27
- : []),
28
- ];
25
+ const entries = [
26
+ { provider: "yahoo" as const, fn: () => getQuote(symbol) },
27
+ ...(apiKey
28
+ ? [{ provider: "alphavantage" as const, fn: () => getGlobalQuote(symbol, apiKey) }]
29
+ : []),
30
+ ];
29
31
 
30
- const result = await withFallback(entries);
31
- if (result.status === "unavailable") {
32
- return {
33
- content: [
34
- { type: "text", text: `⚠ Stock quote unavailable for ${symbol} (${result.reason}).` },
35
- ],
36
- details: null as any,
37
- };
38
- }
39
- const quote = result.data;
40
- const sign = quote.changePercent >= 0 ? "+" : "";
32
+ const result = await withFallback(entries);
33
+ if (result.status === "unavailable") {
34
+ return {
35
+ content: [
36
+ { type: "text", text: `⚠ Stock quote unavailable for ${symbol} (${result.reason}).` },
37
+ ],
38
+ details: null as any,
39
+ };
40
+ }
41
+ const quote = result.data;
42
+ const freshness = buildFreshnessStamp({
43
+ asOf: quote.asOf,
44
+ cached: result.cached,
45
+ stale: result.stale,
46
+ cachedAt: result.cached || result.stale ? result.timestamp : undefined,
47
+ });
48
+ const sign = quote.changePercent >= 0 ? "+" : "";
41
49
 
42
- const week52 =
43
- quote.week52High > 0 && quote.week52Low > 0
44
- ? `$${quote.week52Low.toFixed(2)} - $${quote.week52High.toFixed(2)}`
45
- : "N/A";
46
- const marketCapStr = quote.marketCap > 0 ? `$${formatLargeNumber(quote.marketCap)}` : "N/A";
50
+ const week52 =
51
+ quote.week52High > 0 && quote.week52Low > 0
52
+ ? `$${quote.week52Low.toFixed(2)} - $${quote.week52High.toFixed(2)}`
53
+ : "N/A";
54
+ const marketCapStr = quote.marketCap > 0 ? `$${formatLargeNumber(quote.marketCap)}` : "N/A";
47
55
 
48
- const text = [
49
- `${quote.symbol}: $${quote.price.toFixed(2)} (${sign}${quote.changePercent.toFixed(2)}%)`,
50
- `Open: $${quote.open.toFixed(2)} | High: $${quote.high.toFixed(2)} | Low: $${quote.low.toFixed(2)}`,
51
- `Volume: ${quote.volume.toLocaleString()} | Market Cap: ${marketCapStr}`,
52
- `52W Range: ${week52}`,
53
- ].join("\n");
56
+ const text = [
57
+ `${quote.symbol}: $${quote.price.toFixed(2)} (${sign}${quote.changePercent.toFixed(2)}%)`,
58
+ `Open: $${quote.open.toFixed(2)} | High: $${quote.high.toFixed(2)} | Low: $${quote.low.toFixed(2)}`,
59
+ `Volume: ${quote.volume.toLocaleString()} | Market Cap: ${marketCapStr}`,
60
+ `52W Range: ${week52}`,
61
+ formatAsOfLine(freshness),
62
+ ].join("\n");
54
63
 
55
- const prefix = result.stale
56
- ? `⚠ Using cached quote from ${result.timestamp} (provider rate limited)\n`
57
- : "";
58
- return { content: [{ type: "text", text: prefix + text }], details: quote };
59
- },
60
- };
64
+ return { content: [{ type: "text", text }], details: { ...quote, freshness } };
65
+ },
66
+ };
61
67
 
62
68
  function formatLargeNumber(n: number): string {
63
69
  if (n >= 1e12) return `${(n / 1e12).toFixed(2)}T`;
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
+ import { buildFreshnessStamp, type FreshnessStamp, formatAsOfLine } from "../../infra/freshness.js";
3
4
  import { wrapProvider } from "../../providers/wrap-provider.js";
4
5
  import { getOptionsChain } from "../../providers/yahoo-finance.js";
5
6
  import type { OptionContract, OptionsChain } from "../../types/options.js";
@@ -22,7 +23,10 @@ const params = Type.Object({
22
23
  ),
23
24
  });
24
25
 
25
- export const optionChainTool: AgentTool<typeof params, OptionsChain> = {
26
+ export const optionChainTool: AgentTool<
27
+ typeof params,
28
+ OptionsChain & { freshness: FreshnessStamp }
29
+ > = {
26
30
  name: "get_option_chain",
27
31
  label: "Options Chain",
28
32
  description:
@@ -43,6 +47,12 @@ export const optionChainTool: AgentTool<typeof params, OptionsChain> = {
43
47
  };
44
48
  }
45
49
  const chain = result.data;
50
+ const freshness = buildFreshnessStamp({
51
+ asOf: chain.asOf,
52
+ cached: result.cached,
53
+ stale: result.stale,
54
+ cachedAt: result.cached || result.stale ? result.timestamp : undefined,
55
+ });
46
56
 
47
57
  const lines: string[] = [
48
58
  `**${chain.symbol} Options Chain** — Expiry: ${chain.expirationDate}`,
@@ -90,8 +100,12 @@ export const optionChainTool: AgentTool<typeof params, OptionsChain> = {
90
100
  }
91
101
 
92
102
  lines.push(`Put/Call Ratio: ${chain.putCallRatio.toFixed(2)}`);
103
+ lines.push(formatAsOfLine(freshness));
93
104
 
94
- return { content: [{ type: "text", text: lines.join("\n") }], details: chain };
105
+ return {
106
+ content: [{ type: "text", text: lines.join("\n") }],
107
+ details: { ...chain, freshness },
108
+ };
95
109
  },
96
110
  };
97
111
 
@@ -1,7 +1,7 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import {
4
- getOrCreateDefaultWatchlistReportTemplate,
4
+ findDefaultWatchlistReportTemplate,
5
5
  nextDailyReportRunAt,
6
6
  recordDailyWatchlistReportRun,
7
7
  targetsDefaultWatchlist,
@@ -84,14 +84,20 @@ export const dailyReportTool: AgentTool<typeof params> = {
84
84
  return { content: [{ type: "text", text: lines.join("\n") }], details: runs };
85
85
  }
86
86
 
87
- const template = getOrCreateDefaultWatchlistReportTemplate(service);
87
+ // Manual runs link to the configured schedule when one exists but never
88
+ // create a template as a side effect — generation must not imply a
89
+ // schedule the user did not set up.
90
+ const template = findDefaultWatchlistReportTemplate(service);
88
91
  const { report, run } = await recordDailyWatchlistReportRun(service, {
89
- templateId: template.id,
92
+ templateId: template?.id ?? null,
90
93
  triggerType: "manual",
91
94
  });
92
95
  await deliverPendingNotifications(service);
96
+ const text = template
97
+ ? report.text
98
+ : `${report.text}\n\n(Unscheduled manual run — use the configure action to set up a morning report that runs while OpenCandle is open.)`;
93
99
  return {
94
- content: [{ type: "text", text: report.text }],
100
+ content: [{ type: "text", text }],
95
101
  details: run,
96
102
  };
97
103
  } finally {
@@ -169,5 +169,5 @@ function riskSummary(m: RiskMetrics): string {
169
169
  if (m.sharpeRatio < 0) signals.push("Negative risk-adjusted returns");
170
170
  if (m.sharpeRatio >= 1.5) signals.push("Strong risk-adjusted performance");
171
171
  if (m.var95 > 0.03) signals.push("High daily VaR (>3%)");
172
- return signals.length > 0 ? "Flags: " + signals.join(" | ") : "Risk profile appears moderate";
172
+ return signals.length > 0 ? `Flags: ${signals.join(" | ")}` : "Risk profile appears moderate";
173
173
  }
@@ -7,141 +7,165 @@ import { computeRSI, computeSMA } from "./indicators.js";
7
7
 
8
8
  export type Strategy = "sma_crossover" | "sma_50_200_crossover" | "rsi_mean_reversion";
9
9
 
10
+ /** Flat per-side transaction cost assumption, in basis points. */
11
+ export const DEFAULT_COST_BPS = 5;
12
+
13
+ /** What this simulator deliberately does not model. Rendered in tool output. */
14
+ export const BACKTEST_LIMITATIONS = [
15
+ "Fills are simulated at the next bar's open after a signal; intrabar moves and gaps beyond that open are not modeled",
16
+ "Only a flat per-side cost is applied — no slippage model, spreads, or market impact",
17
+ "Dividends, taxes, and financing costs are excluded",
18
+ "Liquidity is assumed unlimited at the fill price",
19
+ ];
20
+
21
+ export interface BacktestOptions {
22
+ /** Flat per-side cost in basis points applied to every fill. */
23
+ costBps?: number;
24
+ }
25
+
26
+ export interface BacktestTrade {
27
+ type: "buy" | "sell";
28
+ date: string;
29
+ price: number;
30
+ pnl?: number;
31
+ forced?: boolean;
32
+ }
33
+
10
34
  export interface BacktestResult {
11
35
  strategy: string;
12
36
  totalReturn: number;
13
37
  buyAndHoldReturn: number;
14
38
  trades: number;
39
+ forcedClosures: number;
15
40
  wins: number;
16
41
  winRate: number;
17
42
  maxDrawdown: number;
18
- tradeLog: Array<{ type: "buy" | "sell"; date: string; price: number; pnl?: number }>;
43
+ costBpsPerSide: number;
44
+ /** Signal generated on the final bar that has no next open to fill at. */
45
+ pendingSignal: { type: "buy" | "sell"; date: string } | null;
46
+ tradeLog: BacktestTrade[];
19
47
  }
20
48
 
21
- export function runBacktest(bars: OHLCV[], strategy: Strategy): BacktestResult {
22
- const closes = bars.map((b) => b.close);
49
+ type Signal = "enter" | "exit" | null;
23
50
 
24
- if (strategy === "sma_crossover") {
25
- return backtestSMACrossover(bars, closes, 20, 50, strategy);
51
+ export function runBacktest(
52
+ bars: OHLCV[],
53
+ strategy: Strategy,
54
+ options?: BacktestOptions,
55
+ ): BacktestResult {
56
+ const closes = bars.map((b) => b.close);
57
+ const costBps = options?.costBps ?? DEFAULT_COST_BPS;
58
+
59
+ if (strategy === "sma_crossover" || strategy === "sma_50_200_crossover") {
60
+ const [shortWindow, longWindow] = strategy === "sma_crossover" ? [20, 50] : [50, 200];
61
+ const shortSma = computeSMA(closes, shortWindow);
62
+ const longSma = computeSMA(closes, longWindow);
63
+ if (longSma.length === 0) {
64
+ return emptyResult(strategy, closes, costBps);
65
+ }
66
+ const signalAt = (barIdx: number): Signal => {
67
+ const sShort = shortSma[barIdx - (shortWindow - 1)];
68
+ const sLong = longSma[barIdx - (longWindow - 1)];
69
+ if (sShort > sLong) return "enter";
70
+ if (sShort < sLong) return "exit";
71
+ return null;
72
+ };
73
+ return simulate(bars, closes, signalAt, longWindow - 1, strategy, costBps);
26
74
  }
27
- if (strategy === "sma_50_200_crossover") {
28
- return backtestSMACrossover(bars, closes, 50, 200, strategy);
75
+
76
+ const rsi = computeRSI(closes, 14);
77
+ if (rsi.length === 0) {
78
+ return emptyResult("rsi_mean_reversion", closes, costBps);
29
79
  }
30
- return backtestRSIMeanReversion(bars, closes);
80
+ const rsiOffset = 14;
81
+ const signalAt = (barIdx: number): Signal => {
82
+ const r = rsi[barIdx - rsiOffset];
83
+ if (r < 30) return "enter";
84
+ if (r > 70) return "exit";
85
+ return null;
86
+ };
87
+ return simulate(bars, closes, signalAt, rsiOffset, "rsi_mean_reversion", costBps);
31
88
  }
32
89
 
33
- function backtestSMACrossover(
90
+ // Shared fill engine: a signal computed from bar N's close fills at bar N+1's
91
+ // open (no same-bar lookahead), with a flat per-side cost on every fill. A
92
+ // signal on the final bar is reported as pending instead of a phantom trade.
93
+ function simulate(
34
94
  bars: OHLCV[],
35
95
  closes: number[],
36
- shortWindow: number,
37
- longWindow: number,
96
+ signalAt: (barIdx: number) => Signal,
97
+ startIdx: number,
38
98
  strategyName: Strategy,
99
+ costBps: number,
39
100
  ): BacktestResult {
40
- const shortSma = computeSMA(closes, shortWindow);
41
- const longSma = computeSMA(closes, longWindow);
42
-
43
- if (longSma.length === 0) {
44
- return emptyResult(strategyName, closes);
45
- }
46
-
47
- const shortOffset = shortWindow - 1;
48
- const longOffset = longWindow - 1;
49
-
101
+ const cost = costBps / 10_000;
50
102
  let position = false;
51
- let entryPrice = 0;
103
+ let entryFill = 0;
104
+ let pendingAction: Signal = null;
105
+ let pendingSignal: BacktestResult["pendingSignal"] = null;
52
106
  const tradeLog: BacktestResult["tradeLog"] = [];
53
107
  let equity = 1.0;
54
108
  let peak = 1.0;
55
109
  let maxDd = 0;
56
110
 
57
- for (let i = 0; i < longSma.length; i++) {
58
- const barIdx = i + longOffset;
59
- const shortSmaIdx = i + (longOffset - shortOffset);
60
- const sShort = shortSma[shortSmaIdx];
61
- const sLong = longSma[i];
62
- const price = closes[barIdx];
63
-
64
- if (!position && sShort > sLong && price > 0) {
65
- // Buy signal
66
- position = true;
67
- entryPrice = price;
68
- tradeLog.push({ type: "buy", date: bars[barIdx].date, price });
69
- } else if (position && sShort < sLong) {
70
- // Sell signal
71
- const pnl = (price - entryPrice) / entryPrice;
72
- equity *= 1 + pnl;
73
- tradeLog.push({ type: "sell", date: bars[barIdx].date, price, pnl });
74
- position = false;
111
+ for (let i = startIdx; i < bars.length; i++) {
112
+ // Execute the previous bar's signal at this bar's open.
113
+ if (pendingAction === "enter" && !position) {
114
+ const fillPrice = bars[i].open;
115
+ if (fillPrice > 0) {
116
+ position = true;
117
+ entryFill = fillPrice * (1 + cost);
118
+ tradeLog.push({ type: "buy", date: bars[i].date, price: fillPrice });
119
+ }
120
+ } else if (pendingAction === "exit" && position) {
121
+ const fillPrice = bars[i].open;
122
+ if (fillPrice > 0 && entryFill > 0) {
123
+ const pnl = (fillPrice * (1 - cost) - entryFill) / entryFill;
124
+ equity *= 1 + pnl;
125
+ tradeLog.push({ type: "sell", date: bars[i].date, price: fillPrice, pnl });
126
+ position = false;
127
+ }
128
+ }
129
+ pendingAction = null;
130
+
131
+ // Evaluate the signal at this bar's close.
132
+ const signal = signalAt(i);
133
+ if ((signal === "enter" && !position) || (signal === "exit" && position)) {
134
+ if (i + 1 >= bars.length) {
135
+ pendingSignal = { type: signal === "enter" ? "buy" : "sell", date: bars[i].date };
136
+ } else {
137
+ pendingAction = signal;
138
+ }
75
139
  }
76
140
 
77
- // Track mark-to-market equity for accurate drawdown
78
- const currentEquity = position ? equity * (1 + (price - entryPrice) / entryPrice) : equity;
141
+ // Track mark-to-market equity for accurate drawdown.
142
+ const price = closes[i];
143
+ const currentEquity =
144
+ position && entryFill > 0 && price > 0
145
+ ? equity * (1 + (price - entryFill) / entryFill)
146
+ : equity;
79
147
  if (currentEquity > peak) peak = currentEquity;
80
148
  const dd = (peak - currentEquity) / peak;
81
149
  if (dd > maxDd) maxDd = dd;
82
150
  }
83
151
 
84
- // Close open position at end
85
- if (position) {
152
+ // Close any open position at the final close for reporting.
153
+ if (position && entryFill > 0) {
86
154
  const lastPrice = closes[closes.length - 1];
87
- const pnl = (lastPrice - entryPrice) / entryPrice;
88
- equity *= 1 + pnl;
89
- tradeLog.push({ type: "sell", date: bars[bars.length - 1].date, price: lastPrice, pnl });
90
- }
91
-
92
- return buildResult(strategyName, equity - 1, closes, tradeLog, maxDd);
93
- }
94
-
95
- function backtestRSIMeanReversion(bars: OHLCV[], closes: number[]): BacktestResult {
96
- const rsi = computeRSI(closes, 14);
97
-
98
- if (rsi.length === 0) {
99
- return emptyResult("rsi_mean_reversion", closes);
100
- }
101
-
102
- // RSI starts at index 14 (after 14 periods of data)
103
- const rsiOffset = 14;
104
- let position = false;
105
- let entryPrice = 0;
106
- const tradeLog: BacktestResult["tradeLog"] = [];
107
- let equity = 1.0;
108
- let peak = 1.0;
109
- let maxDd = 0;
110
-
111
- for (let i = 0; i < rsi.length; i++) {
112
- const barIdx = i + rsiOffset;
113
- const r = rsi[i];
114
- const price = closes[barIdx];
115
-
116
- if (!position && r < 30 && price > 0) {
117
- // RSI oversold → buy
118
- position = true;
119
- entryPrice = price;
120
- tradeLog.push({ type: "buy", date: bars[barIdx].date, price });
121
- } else if (position && r > 70) {
122
- // RSI overbought → sell
123
- const pnl = (price - entryPrice) / entryPrice;
155
+ if (lastPrice > 0) {
156
+ const pnl = (lastPrice * (1 - cost) - entryFill) / entryFill;
124
157
  equity *= 1 + pnl;
125
- tradeLog.push({ type: "sell", date: bars[barIdx].date, price, pnl });
126
- position = false;
158
+ tradeLog.push({
159
+ type: "sell",
160
+ date: bars[bars.length - 1].date,
161
+ price: lastPrice,
162
+ pnl,
163
+ forced: true,
164
+ });
127
165
  }
128
-
129
- // Track mark-to-market equity for accurate drawdown
130
- const currentEquity = position ? equity * (1 + (price - entryPrice) / entryPrice) : equity;
131
- if (currentEquity > peak) peak = currentEquity;
132
- const dd = (peak - currentEquity) / peak;
133
- if (dd > maxDd) maxDd = dd;
134
- }
135
-
136
- // Close open position at end
137
- if (position) {
138
- const lastPrice = closes[closes.length - 1];
139
- const pnl = (lastPrice - entryPrice) / entryPrice;
140
- equity *= 1 + pnl;
141
- tradeLog.push({ type: "sell", date: bars[bars.length - 1].date, price: lastPrice, pnl });
142
166
  }
143
167
 
144
- return buildResult("rsi_mean_reversion", equity - 1, closes, tradeLog, maxDd);
168
+ return buildResult(strategyName, equity - 1, closes, tradeLog, maxDd, costBps, pendingSignal);
145
169
  }
146
170
 
147
171
  function buildResult(
@@ -150,9 +174,15 @@ function buildResult(
150
174
  closes: number[],
151
175
  tradeLog: BacktestResult["tradeLog"],
152
176
  maxDrawdown: number,
177
+ costBpsPerSide: number,
178
+ pendingSignal: BacktestResult["pendingSignal"],
153
179
  ): BacktestResult {
154
- const sellTrades = tradeLog.filter((t) => t.type === "sell" && t.pnl != null);
155
- const wins = sellTrades.filter((t) => t.pnl! > 0).length;
180
+ const sellTrades = tradeLog.filter(
181
+ (t): t is BacktestTrade & { type: "sell"; pnl: number } =>
182
+ t.type === "sell" && t.pnl != null && !t.forced,
183
+ );
184
+ const forcedClosures = tradeLog.filter((t) => t.type === "sell" && t.forced).length;
185
+ const wins = sellTrades.filter((t) => t.pnl > 0).length;
156
186
  const buyAndHoldReturn =
157
187
  closes.length > 1 && closes[0] > 0 ? (closes[closes.length - 1] - closes[0]) / closes[0] : 0;
158
188
 
@@ -161,23 +191,29 @@ function buildResult(
161
191
  totalReturn,
162
192
  buyAndHoldReturn,
163
193
  trades: sellTrades.length,
194
+ forcedClosures,
164
195
  wins,
165
196
  winRate: sellTrades.length > 0 ? wins / sellTrades.length : 0,
166
197
  maxDrawdown,
198
+ costBpsPerSide,
199
+ pendingSignal,
167
200
  tradeLog,
168
201
  };
169
202
  }
170
203
 
171
- function emptyResult(strategy: string, closes: number[]): BacktestResult {
204
+ function emptyResult(strategy: string, closes: number[], costBpsPerSide: number): BacktestResult {
172
205
  return {
173
206
  strategy,
174
207
  totalReturn: 0,
175
208
  buyAndHoldReturn:
176
209
  closes.length > 1 && closes[0] > 0 ? (closes[closes.length - 1] - closes[0]) / closes[0] : 0,
177
210
  trades: 0,
211
+ forcedClosures: 0,
178
212
  wins: 0,
179
213
  winRate: 0,
180
214
  maxDrawdown: 0,
215
+ costBpsPerSide,
216
+ pendingSignal: null,
181
217
  tradeLog: [],
182
218
  };
183
219
  }
@@ -200,13 +236,20 @@ const params = Type.Object({
200
236
  description: "Historical period to backtest: 1y, 2y, 5y. Default: 2y",
201
237
  }),
202
238
  ),
239
+ cost_bps: Type.Optional(
240
+ Type.Number({
241
+ minimum: 0,
242
+ maximum: 100,
243
+ description: `Flat per-side transaction cost in basis points applied to every fill. Default: ${DEFAULT_COST_BPS}`,
244
+ }),
245
+ ),
203
246
  });
204
247
 
205
248
  export const backtestTool: AgentTool<typeof params> = {
206
249
  name: "backtest_strategy",
207
250
  label: "Backtest Strategy",
208
251
  description:
209
- "Backtest a simple trading strategy against historical data. Supported strategies: SMA crossover (SMA20/SMA50), standard long-term SMA crossover (SMA50/SMA200), and RSI mean-reversion (buy <30, sell >70). Returns total return, win rate, max drawdown, and comparison to buy-and-hold.",
252
+ "Replay a simple trading strategy against historical daily bars. Signals fill at the next bar's open with a flat per-side cost assumption; dividends, taxes, slippage beyond that cost, and liquidity are not modeled. Supported strategies: SMA crossover (SMA20/SMA50), standard long-term SMA crossover (SMA50/SMA200), and RSI mean-reversion (buy <30, sell >70). Returns total return, win rate, max drawdown, and comparison to buy-and-hold.",
210
253
  parameters: params,
211
254
  async execute(_toolCallId, args) {
212
255
  const symbol = args.symbol.toUpperCase();
@@ -235,22 +278,38 @@ export const backtestTool: AgentTool<typeof params> = {
235
278
  };
236
279
  }
237
280
 
238
- const result = runBacktest(bars, args.strategy);
281
+ const costBps = args.cost_bps ?? DEFAULT_COST_BPS;
282
+ const result = runBacktest(bars, args.strategy, { costBps });
239
283
 
240
284
  const outperformance = result.totalReturn - result.buyAndHoldReturn;
241
285
  const lines = [
242
286
  `**${symbol} Backtest: ${strategyLabel(args.strategy)}** (${bars[0].date} to ${bars[bars.length - 1].date}, ${bars.length} days)`,
243
287
  ``,
244
- `Strategy Return: ${(result.totalReturn * 100).toFixed(2)}%`,
245
- `Buy & Hold Return: ${(result.buyAndHoldReturn * 100).toFixed(2)}%`,
288
+ `Strategy Return (net of costs): ${(result.totalReturn * 100).toFixed(2)}%`,
289
+ `Buy & Hold Return (gross): ${(result.buyAndHoldReturn * 100).toFixed(2)}%`,
246
290
  `Outperformance: ${outperformance >= 0 ? "+" : ""}${(outperformance * 100).toFixed(2)}%`,
247
291
  ``,
248
- `Trades: ${result.trades} | Wins: ${result.wins} | Win Rate: ${(result.winRate * 100).toFixed(0)}%`,
292
+ `Signal Trades: ${result.trades} | Forced Closures: ${result.forcedClosures} | Wins: ${result.wins} | Win Rate: ${(result.winRate * 100).toFixed(0)}%`,
249
293
  `Max Drawdown: ${(result.maxDrawdown * 100).toFixed(2)}%`,
250
294
  ``,
295
+ `Execution assumptions: signals fill at the next bar's open with a flat cost of ${costBps} bps per side.`,
296
+ ...(result.pendingSignal
297
+ ? [
298
+ `Note: a ${result.pendingSignal.type} signal fired on the final bar (${result.pendingSignal.date}) and is pending — it has no next open to fill at and is not counted as a signal trade.`,
299
+ ]
300
+ : []),
301
+ ...(result.forcedClosures > 0
302
+ ? [
303
+ `Note: ${result.forcedClosures} open position${result.forcedClosures === 1 ? " was" : "s were"} liquidated at the final close for return reporting; forced closures are not counted as signal trades.`,
304
+ ]
305
+ : []),
306
+ ``,
251
307
  result.totalReturn > result.buyAndHoldReturn
252
- ? `Strategy outperformed buy-and-hold by ${(outperformance * 100).toFixed(2)}%.`
253
- : `Buy-and-hold outperformed the strategy by ${(-outperformance * 100).toFixed(2)}%.`,
308
+ ? `In this simulation the strategy outperformed buy-and-hold by ${(outperformance * 100).toFixed(2)}%.`
309
+ : `In this simulation buy-and-hold outperformed the strategy by ${(-outperformance * 100).toFixed(2)}%.`,
310
+ ``,
311
+ `Limitations — this is a strategy replay, not an execution simulation:`,
312
+ ...BACKTEST_LIMITATIONS.map((limit) => `- ${limit}`),
254
313
  ];
255
314
 
256
315
  return {