opencandle 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (564) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +186 -117
  3. package/dist/analysts/contracts.d.ts +1 -3
  4. package/dist/analysts/contracts.js +1 -11
  5. package/dist/analysts/contracts.js.map +1 -1
  6. package/dist/analysts/orchestrator.d.ts +1 -3
  7. package/dist/analysts/orchestrator.js +1 -26
  8. package/dist/analysts/orchestrator.js.map +1 -1
  9. package/dist/cli.js +32 -8
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts +19 -3
  12. package/dist/config.js +69 -3
  13. package/dist/config.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/infra/browser.d.ts +1 -3
  18. package/dist/infra/browser.js +4 -2
  19. package/dist/infra/browser.js.map +1 -1
  20. package/dist/infra/cache.d.ts +8 -11
  21. package/dist/infra/cache.js +17 -15
  22. package/dist/infra/cache.js.map +1 -1
  23. package/dist/infra/http-client.d.ts +4 -1
  24. package/dist/infra/http-client.js +59 -6
  25. package/dist/infra/http-client.js.map +1 -1
  26. package/dist/infra/index.d.ts +3 -3
  27. package/dist/infra/index.js +3 -3
  28. package/dist/infra/index.js.map +1 -1
  29. package/dist/infra/native-dependencies.js +2 -2
  30. package/dist/infra/native-dependencies.js.map +1 -1
  31. package/dist/infra/node-version.js.map +1 -1
  32. package/dist/infra/opencandle-paths.d.ts +0 -3
  33. package/dist/infra/opencandle-paths.js +4 -11
  34. package/dist/infra/opencandle-paths.js.map +1 -1
  35. package/dist/infra/rate-limiter.d.ts +4 -0
  36. package/dist/infra/rate-limiter.js +17 -10
  37. package/dist/infra/rate-limiter.js.map +1 -1
  38. package/dist/market-state/alert-conditions.d.ts +34 -0
  39. package/dist/market-state/alert-conditions.js +23 -0
  40. package/dist/market-state/alert-conditions.js.map +1 -0
  41. package/dist/market-state/alert-runner.d.ts +55 -0
  42. package/dist/market-state/alert-runner.js +634 -0
  43. package/dist/market-state/alert-runner.js.map +1 -0
  44. package/dist/market-state/daily-report.d.ts +26 -0
  45. package/dist/market-state/daily-report.js +179 -0
  46. package/dist/market-state/daily-report.js.map +1 -0
  47. package/dist/market-state/local-automation-service.d.ts +25 -0
  48. package/dist/market-state/local-automation-service.js +119 -0
  49. package/dist/market-state/local-automation-service.js.map +1 -0
  50. package/dist/market-state/notification-delivery.d.ts +14 -0
  51. package/dist/market-state/notification-delivery.js +139 -0
  52. package/dist/market-state/notification-delivery.js.map +1 -0
  53. package/dist/market-state/resolve-for-mutation.d.ts +10 -0
  54. package/dist/market-state/resolve-for-mutation.js +15 -0
  55. package/dist/market-state/resolve-for-mutation.js.map +1 -0
  56. package/dist/market-state/resolve.d.ts +14 -0
  57. package/dist/market-state/resolve.js +89 -0
  58. package/dist/market-state/resolve.js.map +1 -0
  59. package/dist/market-state/service.d.ts +527 -0
  60. package/dist/market-state/service.js +1099 -0
  61. package/dist/market-state/service.js.map +1 -0
  62. package/dist/memory/index.d.ts +7 -7
  63. package/dist/memory/index.js +6 -6
  64. package/dist/memory/index.js.map +1 -1
  65. package/dist/memory/manager.d.ts +9 -0
  66. package/dist/memory/manager.js +39 -22
  67. package/dist/memory/manager.js.map +1 -1
  68. package/dist/memory/retrieval.js +7 -4
  69. package/dist/memory/retrieval.js.map +1 -1
  70. package/dist/memory/sqlite.js +385 -3
  71. package/dist/memory/sqlite.js.map +1 -1
  72. package/dist/memory/storage.d.ts +3 -2
  73. package/dist/memory/storage.js +1 -2
  74. package/dist/memory/storage.js.map +1 -1
  75. package/dist/memory/tool-defaults.js +64 -28
  76. package/dist/memory/tool-defaults.js.map +1 -1
  77. package/dist/memory/types.js +4 -0
  78. package/dist/memory/types.js.map +1 -1
  79. package/dist/monitor.d.ts +2 -0
  80. package/dist/monitor.js +104 -0
  81. package/dist/monitor.js.map +1 -0
  82. package/dist/onboarding/connect.js +4 -6
  83. package/dist/onboarding/connect.js.map +1 -1
  84. package/dist/onboarding/credential-interceptor.js +1 -1
  85. package/dist/onboarding/credential-interceptor.js.map +1 -1
  86. package/dist/onboarding/degradation-accumulator.js +1 -3
  87. package/dist/onboarding/degradation-accumulator.js.map +1 -1
  88. package/dist/onboarding/providers.js +3 -16
  89. package/dist/onboarding/providers.js.map +1 -1
  90. package/dist/onboarding/state.js.map +1 -1
  91. package/dist/onboarding/tool-helpers.js +1 -1
  92. package/dist/onboarding/tool-helpers.js.map +1 -1
  93. package/dist/onboarding/tool-tags.js +6 -4
  94. package/dist/onboarding/tool-tags.js.map +1 -1
  95. package/dist/onboarding/validation.js +1 -1
  96. package/dist/onboarding/validation.js.map +1 -1
  97. package/dist/pi/opencandle-extension.d.ts +8 -0
  98. package/dist/pi/opencandle-extension.js +637 -59
  99. package/dist/pi/opencandle-extension.js.map +1 -1
  100. package/dist/pi/session.d.ts +1 -1
  101. package/dist/pi/session.js +3 -1
  102. package/dist/pi/session.js.map +1 -1
  103. package/dist/pi/setup.js +17 -2
  104. package/dist/pi/setup.js.map +1 -1
  105. package/dist/pi/tool-adapter.js +5 -2
  106. package/dist/pi/tool-adapter.js.map +1 -1
  107. package/dist/prompts/context-builder.d.ts +18 -3
  108. package/dist/prompts/context-builder.js +117 -18
  109. package/dist/prompts/context-builder.js.map +1 -1
  110. package/dist/prompts/disclaimer.js +1 -1
  111. package/dist/prompts/disclaimer.js.map +1 -1
  112. package/dist/prompts/policy-cards.d.ts +13 -0
  113. package/dist/prompts/policy-cards.js +197 -0
  114. package/dist/prompts/policy-cards.js.map +1 -0
  115. package/dist/prompts/sections.d.ts +1 -1
  116. package/dist/prompts/sections.js +3 -3
  117. package/dist/prompts/sections.js.map +1 -1
  118. package/dist/prompts/symbol-preflight.d.ts +20 -0
  119. package/dist/prompts/symbol-preflight.js +49 -0
  120. package/dist/prompts/symbol-preflight.js.map +1 -0
  121. package/dist/prompts/workflow-prompts.d.ts +1 -1
  122. package/dist/prompts/workflow-prompts.js +209 -19
  123. package/dist/prompts/workflow-prompts.js.map +1 -1
  124. package/dist/providers/alpha-vantage.d.ts +1 -1
  125. package/dist/providers/alpha-vantage.js +49 -8
  126. package/dist/providers/alpha-vantage.js.map +1 -1
  127. package/dist/providers/coingecko.js +1 -1
  128. package/dist/providers/coingecko.js.map +1 -1
  129. package/dist/providers/errors.d.ts +5 -0
  130. package/dist/providers/errors.js +11 -0
  131. package/dist/providers/errors.js.map +1 -0
  132. package/dist/providers/exa-search.d.ts +2 -2
  133. package/dist/providers/exa-search.js +19 -11
  134. package/dist/providers/exa-search.js.map +1 -1
  135. package/dist/providers/fear-greed.js +1 -1
  136. package/dist/providers/fear-greed.js.map +1 -1
  137. package/dist/providers/finnhub.js +3 -5
  138. package/dist/providers/finnhub.js.map +1 -1
  139. package/dist/providers/fred.js +2 -2
  140. package/dist/providers/fred.js.map +1 -1
  141. package/dist/providers/index.d.ts +7 -6
  142. package/dist/providers/index.js +6 -5
  143. package/dist/providers/index.js.map +1 -1
  144. package/dist/providers/reddit.js +2 -2
  145. package/dist/providers/reddit.js.map +1 -1
  146. package/dist/providers/sec-edgar.d.ts +9 -1
  147. package/dist/providers/sec-edgar.js +181 -6
  148. package/dist/providers/sec-edgar.js.map +1 -1
  149. package/dist/providers/tradingview.d.ts +47 -0
  150. package/dist/providers/tradingview.js +275 -0
  151. package/dist/providers/tradingview.js.map +1 -0
  152. package/dist/providers/twitter.js +6 -8
  153. package/dist/providers/twitter.js.map +1 -1
  154. package/dist/providers/web-search.js +26 -12
  155. package/dist/providers/web-search.js.map +1 -1
  156. package/dist/providers/with-fallback.js +4 -2
  157. package/dist/providers/with-fallback.js.map +1 -1
  158. package/dist/providers/wrap-provider.d.ts +2 -3
  159. package/dist/providers/wrap-provider.js +14 -8
  160. package/dist/providers/wrap-provider.js.map +1 -1
  161. package/dist/providers/yahoo-finance.d.ts +3 -1
  162. package/dist/providers/yahoo-finance.js +226 -11
  163. package/dist/providers/yahoo-finance.js.map +1 -1
  164. package/dist/routing/classify-intent.d.ts +9 -0
  165. package/dist/routing/classify-intent.js +153 -3
  166. package/dist/routing/classify-intent.js.map +1 -1
  167. package/dist/routing/defaults.d.ts +1 -1
  168. package/dist/routing/defaults.js +3 -3
  169. package/dist/routing/defaults.js.map +1 -1
  170. package/dist/routing/entity-extractor.d.ts +2 -0
  171. package/dist/routing/entity-extractor.js +377 -26
  172. package/dist/routing/entity-extractor.js.map +1 -1
  173. package/dist/routing/fund-symbols.d.ts +2 -0
  174. package/dist/routing/fund-symbols.js +55 -0
  175. package/dist/routing/fund-symbols.js.map +1 -0
  176. package/dist/routing/horizon.d.ts +1 -0
  177. package/dist/routing/horizon.js +10 -0
  178. package/dist/routing/horizon.js.map +1 -0
  179. package/dist/routing/index.d.ts +12 -6
  180. package/dist/routing/index.js +8 -4
  181. package/dist/routing/index.js.map +1 -1
  182. package/dist/routing/legacy-rule-router.d.ts +9 -0
  183. package/dist/routing/legacy-rule-router.js +12 -0
  184. package/dist/routing/legacy-rule-router.js.map +1 -0
  185. package/dist/routing/planning.d.ts +54 -0
  186. package/dist/routing/planning.js +562 -0
  187. package/dist/routing/planning.js.map +1 -0
  188. package/dist/routing/route-manifest.d.ts +35 -0
  189. package/dist/routing/route-manifest.js +242 -0
  190. package/dist/routing/route-manifest.js.map +1 -0
  191. package/dist/routing/router-llm-client.js.map +1 -1
  192. package/dist/routing/router-prompt.js +46 -45
  193. package/dist/routing/router-prompt.js.map +1 -1
  194. package/dist/routing/router-types.d.ts +10 -0
  195. package/dist/routing/router.d.ts +1 -0
  196. package/dist/routing/router.js +572 -13
  197. package/dist/routing/router.js.map +1 -1
  198. package/dist/routing/slot-resolver.d.ts +1 -1
  199. package/dist/routing/slot-resolver.js +45 -7
  200. package/dist/routing/slot-resolver.js.map +1 -1
  201. package/dist/routing/symbol-disambiguator.d.ts +11 -0
  202. package/dist/routing/symbol-disambiguator.js +52 -0
  203. package/dist/routing/symbol-disambiguator.js.map +1 -0
  204. package/dist/routing/turn-context.d.ts +44 -0
  205. package/dist/routing/turn-context.js +45 -0
  206. package/dist/routing/turn-context.js.map +1 -0
  207. package/dist/routing/types.d.ts +15 -1
  208. package/dist/runtime/answer-contracts.d.ts +82 -0
  209. package/dist/runtime/answer-contracts.js +442 -0
  210. package/dist/runtime/answer-contracts.js.map +1 -0
  211. package/dist/runtime/artifact-contracts.d.ts +14 -0
  212. package/dist/runtime/artifact-contracts.js +57 -0
  213. package/dist/runtime/artifact-contracts.js.map +1 -0
  214. package/dist/runtime/planning-evidence.d.ts +99 -0
  215. package/dist/runtime/planning-evidence.js +466 -0
  216. package/dist/runtime/planning-evidence.js.map +1 -0
  217. package/dist/runtime/prompt-step.d.ts +1 -9
  218. package/dist/runtime/prompt-step.js +0 -10
  219. package/dist/runtime/prompt-step.js.map +1 -1
  220. package/dist/runtime/run-context.d.ts +5 -2
  221. package/dist/runtime/run-context.js +8 -1
  222. package/dist/runtime/run-context.js.map +1 -1
  223. package/dist/runtime/session-coordinator.d.ts +29 -3
  224. package/dist/runtime/session-coordinator.js +204 -31
  225. package/dist/runtime/session-coordinator.js.map +1 -1
  226. package/dist/runtime/session-title.d.ts +14 -0
  227. package/dist/runtime/session-title.js +50 -0
  228. package/dist/runtime/session-title.js.map +1 -0
  229. package/dist/runtime/tool-defaults-wrapper.js +1 -3
  230. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  231. package/dist/runtime/validation.js.map +1 -1
  232. package/dist/runtime/workflow-events.js.map +1 -1
  233. package/dist/runtime/workflow-runner.d.ts +3 -3
  234. package/dist/runtime/workflow-runner.js +1 -1
  235. package/dist/runtime/workflow-runner.js.map +1 -1
  236. package/dist/sentiment/adapters/finnhub.d.ts +1 -1
  237. package/dist/sentiment/adapters/finnhub.js +6 -1
  238. package/dist/sentiment/adapters/finnhub.js.map +1 -1
  239. package/dist/sentiment/adapters/reddit.d.ts +2 -2
  240. package/dist/sentiment/adapters/twitter.d.ts +1 -1
  241. package/dist/sentiment/adapters/web.d.ts +1 -1
  242. package/dist/sentiment/index.d.ts +9 -11
  243. package/dist/sentiment/index.js +9 -20
  244. package/dist/sentiment/index.js.map +1 -1
  245. package/dist/sentiment/keywords.js +26 -4
  246. package/dist/sentiment/keywords.js.map +1 -1
  247. package/dist/sentiment/pipeline.d.ts +2 -2
  248. package/dist/sentiment/pipeline.js +1 -1
  249. package/dist/sentiment/pipeline.js.map +1 -1
  250. package/dist/sentiment/scorer.js +1 -1
  251. package/dist/sentiment/store.d.ts +1 -1
  252. package/dist/sentiment/store.js +1 -1
  253. package/dist/sentiment/store.js.map +1 -1
  254. package/dist/sentiment/trends.d.ts +1 -1
  255. package/dist/sentiment/trends.js.map +1 -1
  256. package/dist/sentiment/types.js.map +1 -1
  257. package/dist/system-prompt.js +7 -3
  258. package/dist/system-prompt.js.map +1 -1
  259. package/dist/tool-kit.d.ts +7 -7
  260. package/dist/tool-kit.js +4 -4
  261. package/dist/tool-kit.js.map +1 -1
  262. package/dist/tools/fundamentals/company-overview.js +12 -7
  263. package/dist/tools/fundamentals/company-overview.js.map +1 -1
  264. package/dist/tools/fundamentals/comps.js +19 -10
  265. package/dist/tools/fundamentals/comps.js.map +1 -1
  266. package/dist/tools/fundamentals/dcf.js +24 -12
  267. package/dist/tools/fundamentals/dcf.js.map +1 -1
  268. package/dist/tools/fundamentals/earnings.js +9 -4
  269. package/dist/tools/fundamentals/earnings.js.map +1 -1
  270. package/dist/tools/fundamentals/financials.js +9 -4
  271. package/dist/tools/fundamentals/financials.js.map +1 -1
  272. package/dist/tools/fundamentals/sec-filings.d.ts +1 -0
  273. package/dist/tools/fundamentals/sec-filings.js +36 -4
  274. package/dist/tools/fundamentals/sec-filings.js.map +1 -1
  275. package/dist/tools/index.d.ts +23 -18
  276. package/dist/tools/index.js +53 -38
  277. package/dist/tools/index.js.map +1 -1
  278. package/dist/tools/interaction/ask-user.js +15 -3
  279. package/dist/tools/interaction/ask-user.js.map +1 -1
  280. package/dist/tools/interaction/twitter-login.js +13 -3
  281. package/dist/tools/interaction/twitter-login.js.map +1 -1
  282. package/dist/tools/macro/fear-greed.js +1 -1
  283. package/dist/tools/macro/fear-greed.js.map +1 -1
  284. package/dist/tools/macro/fred-data.d.ts +1 -1
  285. package/dist/tools/macro/fred-data.js +44 -9
  286. package/dist/tools/macro/fred-data.js.map +1 -1
  287. package/dist/tools/market/crypto-history.js +21 -3
  288. package/dist/tools/market/crypto-history.js.map +1 -1
  289. package/dist/tools/market/crypto-price.js +4 -2
  290. package/dist/tools/market/crypto-price.js.map +1 -1
  291. package/dist/tools/market/screen-stocks.d.ts +18 -0
  292. package/dist/tools/market/screen-stocks.js +252 -0
  293. package/dist/tools/market/screen-stocks.js.map +1 -0
  294. package/dist/tools/market/search-ticker.js +161 -9
  295. package/dist/tools/market/search-ticker.js.map +1 -1
  296. package/dist/tools/market/stock-history.d.ts +2 -2
  297. package/dist/tools/market/stock-history.js +27 -8
  298. package/dist/tools/market/stock-history.js.map +1 -1
  299. package/dist/tools/market/stock-quote.js +6 -4
  300. package/dist/tools/market/stock-quote.js.map +1 -1
  301. package/dist/tools/options/greeks.js +1 -2
  302. package/dist/tools/options/greeks.js.map +1 -1
  303. package/dist/tools/options/option-chain.js +27 -9
  304. package/dist/tools/options/option-chain.js.map +1 -1
  305. package/dist/tools/portfolio/alerts.d.ts +15 -0
  306. package/dist/tools/portfolio/alerts.js +357 -0
  307. package/dist/tools/portfolio/alerts.js.map +1 -0
  308. package/dist/tools/portfolio/correlation.d.ts +1 -1
  309. package/dist/tools/portfolio/correlation.js +34 -14
  310. package/dist/tools/portfolio/correlation.js.map +1 -1
  311. package/dist/tools/portfolio/daily-report.d.ts +8 -0
  312. package/dist/tools/portfolio/daily-report.js +83 -0
  313. package/dist/tools/portfolio/daily-report.js.map +1 -0
  314. package/dist/tools/portfolio/holdings-overlap.d.ts +8 -0
  315. package/dist/tools/portfolio/holdings-overlap.js +112 -0
  316. package/dist/tools/portfolio/holdings-overlap.js.map +1 -0
  317. package/dist/tools/portfolio/notifications.d.ts +7 -0
  318. package/dist/tools/portfolio/notifications.js +43 -0
  319. package/dist/tools/portfolio/notifications.js.map +1 -0
  320. package/dist/tools/portfolio/predictions.d.ts +12 -6
  321. package/dist/tools/portfolio/predictions.js +338 -88
  322. package/dist/tools/portfolio/predictions.js.map +1 -1
  323. package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
  324. package/dist/tools/portfolio/risk-analysis.js +46 -7
  325. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  326. package/dist/tools/portfolio/tracker.d.ts +4 -3
  327. package/dist/tools/portfolio/tracker.js +247 -102
  328. package/dist/tools/portfolio/tracker.js.map +1 -1
  329. package/dist/tools/portfolio/watchlist.d.ts +6 -4
  330. package/dist/tools/portfolio/watchlist.js +209 -101
  331. package/dist/tools/portfolio/watchlist.js.map +1 -1
  332. package/dist/tools/sentiment/reddit-sentiment.js +24 -11
  333. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  334. package/dist/tools/sentiment/sentiment-summary.js +71 -14
  335. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  336. package/dist/tools/sentiment/sentiment-trend.d.ts +1 -1
  337. package/dist/tools/sentiment/sentiment-trend.js +12 -2
  338. package/dist/tools/sentiment/sentiment-trend.js.map +1 -1
  339. package/dist/tools/sentiment/twitter-sentiment.js +13 -6
  340. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  341. package/dist/tools/sentiment/untrusted-text.d.ts +2 -0
  342. package/dist/tools/sentiment/untrusted-text.js +17 -0
  343. package/dist/tools/sentiment/untrusted-text.js.map +1 -0
  344. package/dist/tools/sentiment/web-search.js +37 -12
  345. package/dist/tools/sentiment/web-search.js.map +1 -1
  346. package/dist/tools/sentiment/web-sentiment.js +16 -4
  347. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  348. package/dist/tools/technical/backtest.d.ts +3 -3
  349. package/dist/tools/technical/backtest.js +65 -44
  350. package/dist/tools/technical/backtest.js.map +1 -1
  351. package/dist/tools/technical/indicators.js +24 -8
  352. package/dist/tools/technical/indicators.js.map +1 -1
  353. package/dist/types/index.d.ts +3 -3
  354. package/dist/types/index.js.map +1 -1
  355. package/dist/types/market.d.ts +1 -0
  356. package/dist/types/options.d.ts +10 -0
  357. package/dist/types/portfolio.d.ts +41 -4
  358. package/dist/workflows/compare-assets.d.ts +0 -3
  359. package/dist/workflows/compare-assets.js +55 -10
  360. package/dist/workflows/compare-assets.js.map +1 -1
  361. package/dist/workflows/index.d.ts +3 -4
  362. package/dist/workflows/index.js +3 -3
  363. package/dist/workflows/index.js.map +1 -1
  364. package/dist/workflows/options-screener.d.ts +0 -3
  365. package/dist/workflows/options-screener.js +88 -14
  366. package/dist/workflows/options-screener.js.map +1 -1
  367. package/dist/workflows/portfolio-builder.d.ts +0 -3
  368. package/dist/workflows/portfolio-builder.js +7 -11
  369. package/dist/workflows/portfolio-builder.js.map +1 -1
  370. package/gui/server/ask-user-bridge.ts +82 -0
  371. package/gui/server/automation-heartbeat.ts +97 -0
  372. package/gui/server/background-quotes.ts +97 -1
  373. package/gui/server/chat-event-adapter.ts +32 -10
  374. package/gui/server/chat-run-session.ts +16 -0
  375. package/gui/server/gui-session-manager.ts +5 -0
  376. package/gui/server/invoke-tool.ts +144 -1
  377. package/gui/server/live-chat-event-adapter.ts +21 -6
  378. package/gui/server/market-state-api.ts +315 -0
  379. package/gui/server/model-setup.ts +149 -2
  380. package/gui/server/private-api-access.ts +62 -0
  381. package/gui/server/projector.ts +58 -11
  382. package/gui/server/prompt-observation.ts +58 -0
  383. package/gui/server/quote-snapshot-store.ts +50 -0
  384. package/gui/server/server.ts +236 -376
  385. package/gui/server/session-actions.ts +186 -1
  386. package/gui/server/session-entry-wait.ts +81 -0
  387. package/gui/server/shutdown.ts +47 -0
  388. package/gui/server/tool-invoke-ack.ts +49 -0
  389. package/gui/server/tool-metadata.ts +23 -10
  390. package/gui/server/websocket.ts +13 -3
  391. package/gui/server/writer-lock.ts +6 -2
  392. package/gui/server/ws-hub.ts +292 -0
  393. package/gui/shared/chat-events.ts +16 -1
  394. package/gui/shared/event-reducer.ts +24 -6
  395. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +1 -0
  396. package/gui/web/dist/assets/index-2KZtKBmu.css +1 -0
  397. package/gui/web/dist/assets/index-CveNgtDg.js +69 -0
  398. package/gui/web/dist/index.html +2 -2
  399. package/package.json +22 -12
  400. package/src/analysts/contracts.ts +10 -23
  401. package/src/analysts/orchestrator.ts +8 -43
  402. package/src/cli.ts +37 -13
  403. package/src/config.ts +99 -7
  404. package/src/index.ts +1 -1
  405. package/src/infra/browser.ts +4 -2
  406. package/src/infra/cache.ts +41 -30
  407. package/src/infra/http-client.ts +72 -6
  408. package/src/infra/index.ts +7 -10
  409. package/src/infra/native-dependencies.ts +8 -3
  410. package/src/infra/node-version.ts +3 -1
  411. package/src/infra/opencandle-paths.ts +3 -14
  412. package/src/infra/rate-limiter.ts +32 -20
  413. package/src/market-state/alert-conditions.ts +82 -0
  414. package/src/market-state/alert-runner.ts +863 -0
  415. package/src/market-state/daily-report.ts +247 -0
  416. package/src/market-state/local-automation-service.ts +162 -0
  417. package/src/market-state/notification-delivery.ts +158 -0
  418. package/src/market-state/resolve-for-mutation.ts +24 -0
  419. package/src/market-state/resolve.ts +112 -0
  420. package/src/market-state/service.ts +2344 -0
  421. package/src/memory/index.ts +7 -7
  422. package/src/memory/manager.ts +57 -26
  423. package/src/memory/retrieval.ts +8 -7
  424. package/src/memory/sqlite.ts +407 -6
  425. package/src/memory/storage.ts +8 -17
  426. package/src/memory/tool-defaults.ts +60 -39
  427. package/src/memory/types.ts +7 -3
  428. package/src/monitor.ts +121 -0
  429. package/src/onboarding/connect.ts +10 -33
  430. package/src/onboarding/credential-interceptor.ts +3 -15
  431. package/src/onboarding/degradation-accumulator.ts +1 -3
  432. package/src/onboarding/providers.ts +9 -40
  433. package/src/onboarding/state.ts +4 -15
  434. package/src/onboarding/tool-helpers.ts +2 -9
  435. package/src/onboarding/tool-tags.ts +6 -6
  436. package/src/onboarding/validation.ts +14 -20
  437. package/src/pi/opencandle-extension.ts +795 -120
  438. package/src/pi/session.ts +7 -5
  439. package/src/pi/setup.ts +61 -33
  440. package/src/pi/tool-adapter.ts +5 -2
  441. package/src/prompts/context-builder.ts +143 -21
  442. package/src/prompts/disclaimer.ts +1 -1
  443. package/src/prompts/policy-cards.ts +220 -0
  444. package/src/prompts/sections.ts +4 -4
  445. package/src/prompts/symbol-preflight.ts +80 -0
  446. package/src/prompts/workflow-prompts.ts +231 -28
  447. package/src/providers/alpha-vantage.ts +82 -40
  448. package/src/providers/coingecko.ts +2 -5
  449. package/src/providers/errors.ts +9 -0
  450. package/src/providers/exa-search.ts +24 -22
  451. package/src/providers/fear-greed.ts +1 -1
  452. package/src/providers/finnhub.ts +7 -6
  453. package/src/providers/fred.ts +3 -3
  454. package/src/providers/index.ts +14 -6
  455. package/src/providers/reddit.ts +17 -6
  456. package/src/providers/sec-edgar.ts +235 -5
  457. package/src/providers/tradingview.ts +399 -0
  458. package/src/providers/twitter.ts +6 -8
  459. package/src/providers/web-search.ts +30 -20
  460. package/src/providers/with-fallback.ts +8 -7
  461. package/src/providers/wrap-provider.ts +15 -10
  462. package/src/providers/yahoo-finance.ts +292 -20
  463. package/src/routing/classify-intent.ts +186 -4
  464. package/src/routing/defaults.ts +4 -4
  465. package/src/routing/entity-extractor.ts +428 -28
  466. package/src/routing/fund-symbols.ts +58 -0
  467. package/src/routing/horizon.ts +7 -0
  468. package/src/routing/index.ts +60 -16
  469. package/src/routing/legacy-rule-router.ts +13 -0
  470. package/src/routing/planning.ts +823 -0
  471. package/src/routing/route-manifest.ts +309 -0
  472. package/src/routing/router-llm-client.ts +4 -4
  473. package/src/routing/router-prompt.ts +52 -52
  474. package/src/routing/router-types.ts +18 -0
  475. package/src/routing/router.ts +717 -20
  476. package/src/routing/slot-resolver.ts +75 -14
  477. package/src/routing/symbol-disambiguator.ts +72 -0
  478. package/src/routing/turn-context.ts +108 -0
  479. package/src/routing/types.ts +15 -1
  480. package/src/runtime/answer-contracts.ts +672 -0
  481. package/src/runtime/artifact-contracts.ts +77 -0
  482. package/src/runtime/planning-evidence.ts +682 -0
  483. package/src/runtime/prompt-step.ts +1 -16
  484. package/src/runtime/run-context.ts +12 -2
  485. package/src/runtime/session-coordinator.ts +297 -56
  486. package/src/runtime/session-title.ts +60 -0
  487. package/src/runtime/tool-defaults-wrapper.ts +1 -3
  488. package/src/runtime/validation.ts +1 -4
  489. package/src/runtime/workflow-events.ts +7 -7
  490. package/src/runtime/workflow-runner.ts +5 -11
  491. package/src/sentiment/adapters/finnhub.ts +7 -2
  492. package/src/sentiment/adapters/reddit.ts +2 -2
  493. package/src/sentiment/adapters/twitter.ts +1 -1
  494. package/src/sentiment/adapters/web.ts +1 -1
  495. package/src/sentiment/index.ts +16 -26
  496. package/src/sentiment/keywords.ts +26 -4
  497. package/src/sentiment/pipeline.ts +15 -4
  498. package/src/sentiment/scorer.ts +1 -1
  499. package/src/sentiment/store.ts +2 -2
  500. package/src/sentiment/trends.ts +9 -3
  501. package/src/sentiment/types.ts +5 -4
  502. package/src/system-prompt.ts +7 -3
  503. package/src/tool-kit.ts +10 -9
  504. package/src/tools/fundamentals/company-overview.ts +20 -10
  505. package/src/tools/fundamentals/comps.ts +69 -56
  506. package/src/tools/fundamentals/dcf.ts +146 -96
  507. package/src/tools/fundamentals/earnings.ts +17 -7
  508. package/src/tools/fundamentals/financials.ts +17 -8
  509. package/src/tools/fundamentals/sec-filings.ts +52 -8
  510. package/src/tools/index.ts +53 -38
  511. package/src/tools/interaction/ask-user.ts +22 -10
  512. package/src/tools/interaction/twitter-login.ts +17 -5
  513. package/src/tools/macro/fear-greed.ts +2 -2
  514. package/src/tools/macro/fred-data.ts +80 -42
  515. package/src/tools/market/crypto-history.ts +25 -4
  516. package/src/tools/market/crypto-price.ts +7 -7
  517. package/src/tools/market/screen-stocks.ts +279 -0
  518. package/src/tools/market/search-ticker.ts +219 -18
  519. package/src/tools/market/stock-history.ts +38 -13
  520. package/src/tools/market/stock-quote.ts +11 -8
  521. package/src/tools/options/greeks.ts +5 -6
  522. package/src/tools/options/option-chain.ts +47 -18
  523. package/src/tools/portfolio/alerts.ts +457 -0
  524. package/src/tools/portfolio/correlation.ts +48 -21
  525. package/src/tools/portfolio/daily-report.ts +101 -0
  526. package/src/tools/portfolio/holdings-overlap.ts +139 -0
  527. package/src/tools/portfolio/notifications.ts +45 -0
  528. package/src/tools/portfolio/predictions.ts +407 -107
  529. package/src/tools/portfolio/risk-analysis.ts +47 -8
  530. package/src/tools/portfolio/tracker.ts +271 -110
  531. package/src/tools/portfolio/watchlist.ts +251 -116
  532. package/src/tools/sentiment/reddit-sentiment.ts +51 -25
  533. package/src/tools/sentiment/sentiment-summary.ts +116 -35
  534. package/src/tools/sentiment/sentiment-trend.ts +24 -7
  535. package/src/tools/sentiment/twitter-sentiment.ts +23 -16
  536. package/src/tools/sentiment/untrusted-text.ts +21 -0
  537. package/src/tools/sentiment/web-search.ts +52 -16
  538. package/src/tools/sentiment/web-sentiment.ts +27 -11
  539. package/src/tools/technical/backtest.ts +78 -47
  540. package/src/tools/technical/indicators.ts +40 -17
  541. package/src/types/index.ts +8 -3
  542. package/src/types/market.ts +1 -0
  543. package/src/types/options.ts +17 -0
  544. package/src/types/portfolio.ts +46 -4
  545. package/src/types/sentiment.ts +2 -2
  546. package/src/workflows/compare-assets.ts +67 -19
  547. package/src/workflows/index.ts +3 -4
  548. package/src/workflows/options-screener.ts +98 -22
  549. package/src/workflows/portfolio-builder.ts +40 -29
  550. package/dist/runtime/index.d.ts +0 -16
  551. package/dist/runtime/index.js +0 -10
  552. package/dist/runtime/index.js.map +0 -1
  553. package/dist/runtime/provider-ids.d.ts +0 -14
  554. package/dist/runtime/provider-ids.js +0 -14
  555. package/dist/runtime/provider-ids.js.map +0 -1
  556. package/dist/workflows/types.d.ts +0 -4
  557. package/dist/workflows/types.js +0 -2
  558. package/dist/workflows/types.js.map +0 -1
  559. package/gui/web/dist/assets/CatalogOverlay-D1ImSJTe.js +0 -1
  560. package/gui/web/dist/assets/index-DBrWq43L.css +0 -1
  561. package/gui/web/dist/assets/index-RflHaj0y.js +0 -67
  562. package/src/runtime/index.ts +0 -55
  563. package/src/runtime/provider-ids.ts +0 -15
  564. package/src/workflows/types.ts +0 -4
@@ -0,0 +1,2344 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ export type AssetType = "equity" | "etf" | "fund" | "crypto" | "index" | "option" | "unknown";
4
+ export type PredictionDirection = "bullish" | "bearish" | "neutral";
5
+ export type PredictionStatus = "open" | "resolved" | "expired" | "cancelled";
6
+ export type AlertScopeType = "instrument" | "watchlist" | "portfolio";
7
+
8
+ export interface InstrumentInput {
9
+ symbol: string;
10
+ assetType: AssetType | string;
11
+ name?: string | null;
12
+ exchange?: string | null;
13
+ currency?: string | null;
14
+ provider: string;
15
+ providerMetadata?: unknown;
16
+ resolvedAt?: Date;
17
+ aliases?: InstrumentAliasInput[];
18
+ }
19
+
20
+ export interface InstrumentAliasInput {
21
+ source: string;
22
+ sourceSymbol: string;
23
+ sourceExchange?: string | null;
24
+ sourceAssetType?: string | null;
25
+ sourceId?: string | null;
26
+ raw?: unknown;
27
+ }
28
+
29
+ export interface InstrumentAliasLookup {
30
+ source: string;
31
+ sourceSymbol?: string;
32
+ sourceExchange?: string | null;
33
+ sourceAssetType?: string | null;
34
+ sourceId?: string | null;
35
+ }
36
+
37
+ export interface CollectionRecord {
38
+ id: number;
39
+ name: string;
40
+ isDefault: boolean;
41
+ baseCurrency?: string;
42
+ createdAt: string;
43
+ updatedAt: string;
44
+ }
45
+
46
+ export interface InstrumentRecord {
47
+ id: number;
48
+ symbol: string;
49
+ assetType: string;
50
+ name: string | null;
51
+ exchange: string | null;
52
+ currency: string | null;
53
+ provider: string;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ }
57
+
58
+ export interface WatchlistItemRecord {
59
+ id: number;
60
+ watchlistId: number;
61
+ instrumentId: number;
62
+ symbol: string;
63
+ name: string | null;
64
+ assetType: string;
65
+ exchange: string | null;
66
+ currency: string | null;
67
+ targetPrice: number | null;
68
+ stopPrice: number | null;
69
+ priceCurrency: string | null;
70
+ thesis: string | null;
71
+ notes: string | null;
72
+ tags: string[] | null;
73
+ source: string | null;
74
+ sourceRowId: string | null;
75
+ sourceMetadata: unknown;
76
+ createdAt: string;
77
+ updatedAt: string;
78
+ }
79
+
80
+ export interface PortfolioLotRecord {
81
+ id: number;
82
+ portfolioId: number;
83
+ instrumentId: number;
84
+ symbol: string;
85
+ name: string | null;
86
+ assetType: string;
87
+ exchange: string | null;
88
+ instrumentCurrency: string | null;
89
+ quantity: number;
90
+ avgCost: number;
91
+ currency: string;
92
+ openedAt: string | null;
93
+ notes: string | null;
94
+ source: string | null;
95
+ sourceAccountRef: string | null;
96
+ sourceLotId: string | null;
97
+ sourceRowId: string | null;
98
+ sourceMetadata: unknown;
99
+ createdAt: string;
100
+ updatedAt: string;
101
+ }
102
+
103
+ export interface PredictionRecord {
104
+ id: number;
105
+ instrumentId: number;
106
+ symbol: string;
107
+ direction: PredictionDirection;
108
+ conviction: number;
109
+ entryPrice: number;
110
+ targetPrice: number | null;
111
+ openedAt: string;
112
+ expiresAt: string;
113
+ status: PredictionStatus;
114
+ resolvedAt: string | null;
115
+ resultJson: string | null;
116
+ createdAt: string;
117
+ updatedAt: string;
118
+ }
119
+
120
+ export interface AlertRuleRecord {
121
+ id: number;
122
+ scopeType: AlertScopeType;
123
+ scopeId: number | null;
124
+ instrumentId: number | null;
125
+ conditionType: string;
126
+ conditionVersion: number;
127
+ conditionJson: unknown;
128
+ timeframe: string;
129
+ enabled: boolean;
130
+ checkIntervalSeconds: number | null;
131
+ nextCheckAt: string | null;
132
+ lastCheckedAt: string | null;
133
+ lastObservedJson: unknown;
134
+ status: string;
135
+ retriggerMode: string;
136
+ lastConditionState: string;
137
+ ruleRevision: number;
138
+ armCycleId: number;
139
+ cooldownSeconds: number | null;
140
+ lastTriggeredAt: string | null;
141
+ createdAt: string;
142
+ updatedAt: string;
143
+ }
144
+
145
+ export interface AlertEventRecord {
146
+ id: number;
147
+ alertRuleId: number;
148
+ instrumentId: number | null;
149
+ observedValueJson: unknown;
150
+ triggeredAt: string;
151
+ observedAt: string;
152
+ providerDataAt: string | null;
153
+ sourceProvider: string | null;
154
+ cacheStatus: string;
155
+ dataDelayMs: number | null;
156
+ triggerSource: string;
157
+ dedupeKey: string | null;
158
+ status: string;
159
+ message: string | null;
160
+ }
161
+
162
+ export interface AutomationRunnerLeaseRecord {
163
+ ownerId: string;
164
+ ownerKind: string;
165
+ acquiredAt: string;
166
+ heartbeatAt: string;
167
+ expiresAt: string;
168
+ }
169
+
170
+ export interface AutomationRunnerLeaseResult extends AutomationRunnerLeaseRecord {
171
+ acquired: boolean;
172
+ }
173
+
174
+ export interface AlertCheckRunRecord {
175
+ id: number;
176
+ startedAt: string;
177
+ completedAt: string | null;
178
+ status: string;
179
+ triggerType: string;
180
+ checkedCount: number;
181
+ triggeredCount: number;
182
+ unavailableCount: number;
183
+ ownerId: string | null;
184
+ errorJson: unknown;
185
+ providerStatusJson: unknown;
186
+ }
187
+
188
+ export interface NotificationEventRecord {
189
+ id: number;
190
+ sourceType: string;
191
+ sourceId: number | null;
192
+ severity: string;
193
+ title: string;
194
+ body: string;
195
+ payloadJson: unknown;
196
+ status: string;
197
+ createdAt: string;
198
+ acknowledgedAt: string | null;
199
+ }
200
+
201
+ export interface NotificationDeliveryAttemptRecord {
202
+ id: number;
203
+ notificationEventId: number;
204
+ channel: string;
205
+ status: string;
206
+ attemptedAt: string;
207
+ completedAt: string | null;
208
+ responseJson: unknown;
209
+ error: string | null;
210
+ }
211
+
212
+ export interface ReportTemplateRecord {
213
+ id: number;
214
+ name: string;
215
+ reportType: string;
216
+ cadence: string;
217
+ timezone: string;
218
+ localTime: string;
219
+ configJson: unknown;
220
+ enabled: boolean;
221
+ lastRunAt: string | null;
222
+ nextRunAt: string | null;
223
+ createdAt: string;
224
+ updatedAt: string;
225
+ }
226
+
227
+ export interface ReportRunRecord {
228
+ id: number;
229
+ templateId: number | null;
230
+ startedAt: string;
231
+ completedAt: string | null;
232
+ status: string;
233
+ triggerType: string;
234
+ scheduledFor: string | null;
235
+ ownerId: string | null;
236
+ artifactPath: string | null;
237
+ summaryJson: unknown;
238
+ errorsJson: unknown;
239
+ }
240
+
241
+ export interface ImportBatchRecord {
242
+ id: number;
243
+ source: string;
244
+ sourceLabel: string | null;
245
+ importedAt: string;
246
+ status: string;
247
+ rawMetadata: unknown;
248
+ }
249
+
250
+ export interface ImportRowRecord {
251
+ id: number;
252
+ batchId: number;
253
+ rowType: string;
254
+ sourceSymbol: string | null;
255
+ sourceRowId: string | null;
256
+ sourceAccountRef: string | null;
257
+ normalizedInstrumentId: number | null;
258
+ status: string;
259
+ error: string | null;
260
+ sourceMetadata: unknown;
261
+ raw: unknown;
262
+ }
263
+
264
+ interface WatchlistRow {
265
+ id: number;
266
+ name: string;
267
+ is_default: number;
268
+ created_at: string;
269
+ updated_at: string;
270
+ }
271
+
272
+ interface PortfolioRow extends WatchlistRow {
273
+ base_currency: string;
274
+ }
275
+
276
+ interface InstrumentRow {
277
+ id: number;
278
+ symbol: string;
279
+ asset_type: string;
280
+ name: string | null;
281
+ exchange: string | null;
282
+ currency: string | null;
283
+ provider: string;
284
+ provider_metadata_json: string | null;
285
+ last_resolved_at: string;
286
+ created_at: string;
287
+ updated_at: string;
288
+ }
289
+
290
+ interface InstrumentAliasRow {
291
+ id: number;
292
+ instrument_id: number;
293
+ }
294
+
295
+ type WatchlistItemRow = {
296
+ id: number;
297
+ watchlist_id: number;
298
+ instrument_id: number;
299
+ symbol: string;
300
+ name: string | null;
301
+ asset_type: string;
302
+ exchange: string | null;
303
+ currency: string | null;
304
+ target_price: number | null;
305
+ stop_price: number | null;
306
+ price_currency: string | null;
307
+ thesis: string | null;
308
+ notes: string | null;
309
+ tags_json: string | null;
310
+ source: string | null;
311
+ source_row_id: string | null;
312
+ source_metadata_json: string | null;
313
+ created_at: string;
314
+ updated_at: string;
315
+ };
316
+
317
+ type PortfolioLotRow = {
318
+ id: number;
319
+ portfolio_id: number;
320
+ instrument_id: number;
321
+ symbol: string;
322
+ name: string | null;
323
+ asset_type: string;
324
+ exchange: string | null;
325
+ instrument_currency: string | null;
326
+ quantity: number;
327
+ avg_cost: number;
328
+ currency: string;
329
+ opened_at: string | null;
330
+ notes: string | null;
331
+ source: string | null;
332
+ source_account_ref: string | null;
333
+ source_lot_id: string | null;
334
+ source_row_id: string | null;
335
+ source_metadata_json: string | null;
336
+ created_at: string;
337
+ updated_at: string;
338
+ };
339
+
340
+ type PredictionRow = {
341
+ id: number;
342
+ instrument_id: number;
343
+ symbol: string;
344
+ direction: PredictionDirection;
345
+ conviction: number;
346
+ entry_price: number;
347
+ target_price: number | null;
348
+ opened_at: string;
349
+ expires_at: string;
350
+ status: PredictionStatus;
351
+ resolved_at: string | null;
352
+ result_json: string | null;
353
+ created_at: string;
354
+ updated_at: string;
355
+ };
356
+
357
+ type AlertRuleRow = {
358
+ id: number;
359
+ scope_type: AlertScopeType;
360
+ scope_id: number | null;
361
+ instrument_id: number | null;
362
+ condition_type: string;
363
+ condition_version: number;
364
+ condition_json: string;
365
+ timeframe: string;
366
+ enabled: number;
367
+ check_interval_seconds: number | null;
368
+ next_check_at: string | null;
369
+ last_checked_at: string | null;
370
+ last_observed_json: string | null;
371
+ status: string;
372
+ retrigger_mode: string;
373
+ last_condition_state: string;
374
+ rule_revision: number;
375
+ arm_cycle_id: number;
376
+ cooldown_seconds: number | null;
377
+ last_triggered_at: string | null;
378
+ created_at: string;
379
+ updated_at: string;
380
+ };
381
+
382
+ type AlertEventRow = {
383
+ id: number;
384
+ alert_rule_id: number;
385
+ instrument_id: number | null;
386
+ observed_value_json: string | null;
387
+ triggered_at: string;
388
+ observed_at: string;
389
+ provider_data_at: string | null;
390
+ source_provider: string | null;
391
+ cache_status: string;
392
+ data_delay_ms: number | null;
393
+ trigger_source: string;
394
+ dedupe_key: string | null;
395
+ status: string;
396
+ message: string | null;
397
+ };
398
+
399
+ type AutomationRunnerLeaseRow = {
400
+ owner_id: string;
401
+ owner_kind: string;
402
+ acquired_at: string;
403
+ heartbeat_at: string;
404
+ expires_at: string;
405
+ };
406
+
407
+ type AlertCheckRunRow = {
408
+ id: number;
409
+ started_at: string;
410
+ completed_at: string | null;
411
+ status: string;
412
+ trigger_type: string;
413
+ checked_count: number;
414
+ triggered_count: number;
415
+ unavailable_count: number;
416
+ owner_id: string | null;
417
+ error_json: string | null;
418
+ provider_status_json: string | null;
419
+ };
420
+
421
+ type NotificationEventRow = {
422
+ id: number;
423
+ source_type: string;
424
+ source_id: number | null;
425
+ severity: string;
426
+ title: string;
427
+ body: string;
428
+ payload_json: string | null;
429
+ status: string;
430
+ created_at: string;
431
+ acknowledged_at: string | null;
432
+ };
433
+
434
+ type NotificationDeliveryAttemptRow = {
435
+ id: number;
436
+ notification_event_id: number;
437
+ channel: string;
438
+ status: string;
439
+ attempted_at: string;
440
+ completed_at: string | null;
441
+ response_json: string | null;
442
+ error: string | null;
443
+ };
444
+
445
+ type ReportTemplateRow = {
446
+ id: number;
447
+ name: string;
448
+ report_type: string;
449
+ cadence: string;
450
+ timezone: string;
451
+ local_time: string;
452
+ config_json: string;
453
+ enabled: number;
454
+ last_run_at: string | null;
455
+ next_run_at: string | null;
456
+ created_at: string;
457
+ updated_at: string;
458
+ };
459
+
460
+ type ReportRunRow = {
461
+ id: number;
462
+ template_id: number | null;
463
+ started_at: string;
464
+ completed_at: string | null;
465
+ status: string;
466
+ trigger_type: string;
467
+ scheduled_for: string | null;
468
+ owner_id: string | null;
469
+ artifact_path: string | null;
470
+ summary_json: string | null;
471
+ errors_json: string | null;
472
+ };
473
+
474
+ type ImportBatchRow = {
475
+ id: number;
476
+ source: string;
477
+ source_label: string | null;
478
+ imported_at: string;
479
+ status: string;
480
+ raw_metadata_json: string | null;
481
+ };
482
+
483
+ type ImportRowRow = {
484
+ id: number;
485
+ batch_id: number;
486
+ row_type: string;
487
+ source_symbol: string | null;
488
+ source_row_id: string | null;
489
+ source_account_ref: string | null;
490
+ normalized_instrument_id: number | null;
491
+ status: string;
492
+ error: string | null;
493
+ source_metadata_json: string | null;
494
+ raw_json: string | null;
495
+ };
496
+
497
+ export class MarketStateService {
498
+ constructor(private readonly db: Database.Database) {}
499
+
500
+ getDefaultWatchlist(): CollectionRecord {
501
+ const now = new Date().toISOString();
502
+ this.db
503
+ .prepare(
504
+ `INSERT OR IGNORE INTO watchlists (name, is_default, created_at, updated_at)
505
+ SELECT 'Default', 1, ?, ?
506
+ WHERE NOT EXISTS (SELECT 1 FROM watchlists WHERE is_default = 1)`,
507
+ )
508
+ .run(now, now);
509
+
510
+ const row = this.db
511
+ .prepare("SELECT * FROM watchlists WHERE is_default = 1 LIMIT 1")
512
+ .get() as WatchlistRow;
513
+ return mapCollection(row);
514
+ }
515
+
516
+ getDefaultPortfolio(): CollectionRecord {
517
+ const now = new Date().toISOString();
518
+ this.db
519
+ .prepare(
520
+ `INSERT OR IGNORE INTO portfolios (name, base_currency, is_default, created_at, updated_at)
521
+ SELECT 'Default', 'USD', 1, ?, ?
522
+ WHERE NOT EXISTS (SELECT 1 FROM portfolios WHERE is_default = 1)`,
523
+ )
524
+ .run(now, now);
525
+
526
+ const row = this.db
527
+ .prepare("SELECT * FROM portfolios WHERE is_default = 1 LIMIT 1")
528
+ .get() as PortfolioRow;
529
+ return {
530
+ ...mapCollection(row),
531
+ baseCurrency: row.base_currency,
532
+ };
533
+ }
534
+
535
+ findInstrumentByAlias(lookup: InstrumentAliasLookup): InstrumentRecord | null {
536
+ const source = normalizeSource(lookup.source);
537
+ const sourceId = normalizeNullable(lookup.sourceId);
538
+ const alias =
539
+ sourceId == null
540
+ ? (this.db
541
+ .prepare(
542
+ `SELECT id, instrument_id FROM instrument_aliases
543
+ WHERE source = ?
544
+ AND source_symbol = ?
545
+ AND IFNULL(source_exchange, '') = IFNULL(?, '')
546
+ AND IFNULL(source_asset_type, '') = IFNULL(?, '')
547
+ LIMIT 1`,
548
+ )
549
+ .get(
550
+ source,
551
+ normalizeSourceSymbol(lookup.sourceSymbol ?? ""),
552
+ normalizeExchange(lookup.sourceExchange),
553
+ normalizeAssetType(lookup.sourceAssetType),
554
+ ) as InstrumentAliasRow | undefined)
555
+ : (this.db
556
+ .prepare(
557
+ `SELECT id, instrument_id FROM instrument_aliases
558
+ WHERE source = ? AND source_id = ?
559
+ LIMIT 1`,
560
+ )
561
+ .get(source, sourceId) as InstrumentAliasRow | undefined);
562
+
563
+ if (alias == null) return null;
564
+
565
+ const row = this.db
566
+ .prepare("SELECT * FROM instruments WHERE id = ?")
567
+ .get(alias.instrument_id) as InstrumentRow | undefined;
568
+ return row == null ? null : mapInstrument(row);
569
+ }
570
+
571
+ addWatchlistItem(params: {
572
+ instrument: InstrumentInput;
573
+ watchlistId?: number;
574
+ targetPrice?: number | null;
575
+ stopPrice?: number | null;
576
+ priceCurrency?: string | null;
577
+ thesis?: string | null;
578
+ notes?: string | null;
579
+ tags?: string[];
580
+ source?: string;
581
+ sourceRowId?: string;
582
+ sourceMetadata?: unknown;
583
+ }): WatchlistItemRecord {
584
+ const tx = this.db.transaction(() => {
585
+ const watchlistId = params.watchlistId ?? this.getDefaultWatchlist().id;
586
+ const instrument = this.upsertInstrument(params.instrument);
587
+ const now = new Date().toISOString();
588
+ const existing = this.db
589
+ .prepare(
590
+ `SELECT * FROM watchlist_items
591
+ WHERE watchlist_id = ? AND instrument_id = ?`,
592
+ )
593
+ .get(watchlistId, instrument.id) as WatchlistItemRow | undefined;
594
+
595
+ if (existing) {
596
+ this.db
597
+ .prepare(
598
+ `UPDATE watchlist_items
599
+ SET target_price = ?, stop_price = ?, price_currency = ?, thesis = ?,
600
+ notes = ?, tags_json = ?, source = ?, source_row_id = ?,
601
+ source_metadata_json = ?, updated_at = ?
602
+ WHERE id = ?`,
603
+ )
604
+ .run(
605
+ params.targetPrice === undefined ? existing.target_price : params.targetPrice,
606
+ params.stopPrice === undefined ? existing.stop_price : params.stopPrice,
607
+ params.priceCurrency === undefined ? existing.price_currency : params.priceCurrency,
608
+ params.thesis === undefined ? existing.thesis : params.thesis,
609
+ params.notes === undefined ? existing.notes : params.notes,
610
+ params.tags == null ? existing.tags_json : JSON.stringify(params.tags),
611
+ params.source === undefined ? existing.source : normalizeNullable(params.source),
612
+ params.sourceRowId === undefined
613
+ ? existing.source_row_id
614
+ : normalizeNullable(params.sourceRowId),
615
+ params.sourceMetadata === undefined
616
+ ? existing.source_metadata_json
617
+ : params.sourceMetadata == null
618
+ ? null
619
+ : JSON.stringify(params.sourceMetadata),
620
+ now,
621
+ existing.id,
622
+ );
623
+ return existing.id;
624
+ }
625
+
626
+ const result = this.db
627
+ .prepare(
628
+ `INSERT INTO watchlist_items (
629
+ watchlist_id, instrument_id, thesis, notes, tags_json,
630
+ target_price, stop_price, price_currency, source, source_row_id,
631
+ source_metadata_json, created_at, updated_at
632
+ )
633
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
634
+ )
635
+ .run(
636
+ watchlistId,
637
+ instrument.id,
638
+ params.thesis ?? null,
639
+ params.notes ?? null,
640
+ params.tags == null ? null : JSON.stringify(params.tags),
641
+ params.targetPrice ?? null,
642
+ params.stopPrice ?? null,
643
+ params.priceCurrency ?? params.instrument.currency ?? null,
644
+ normalizeNullable(params.source),
645
+ normalizeNullable(params.sourceRowId),
646
+ params.sourceMetadata == null ? null : JSON.stringify(params.sourceMetadata),
647
+ now,
648
+ now,
649
+ );
650
+ return Number(result.lastInsertRowid);
651
+ });
652
+
653
+ return this.getWatchlistItem(tx());
654
+ }
655
+
656
+ listWatchlistItems(watchlistId = this.getDefaultWatchlist().id): WatchlistItemRecord[] {
657
+ const rows = this.db
658
+ .prepare(
659
+ `SELECT wi.*, i.symbol, i.name, i.asset_type, i.exchange, i.currency
660
+ FROM watchlist_items wi
661
+ JOIN instruments i ON i.id = wi.instrument_id
662
+ WHERE wi.watchlist_id = ?
663
+ ORDER BY i.symbol`,
664
+ )
665
+ .all(watchlistId) as WatchlistItemRow[];
666
+ return rows.map(mapWatchlistItem);
667
+ }
668
+
669
+ removeWatchlistItemBySymbol(
670
+ symbol: string,
671
+ watchlistId = this.getDefaultWatchlist().id,
672
+ ): boolean {
673
+ const result = this.db
674
+ .prepare(
675
+ `DELETE FROM watchlist_items
676
+ WHERE watchlist_id = ?
677
+ AND instrument_id IN (SELECT id FROM instruments WHERE symbol = ?)`,
678
+ )
679
+ .run(watchlistId, symbol.trim().toUpperCase());
680
+ return result.changes > 0;
681
+ }
682
+
683
+ updateWatchlistItemBySymbol(
684
+ symbol: string,
685
+ params: {
686
+ watchlistId?: number;
687
+ targetPrice?: number | null;
688
+ stopPrice?: number | null;
689
+ priceCurrency?: string | null;
690
+ thesis?: string | null;
691
+ notes?: string | null;
692
+ tags?: string[];
693
+ },
694
+ ): WatchlistItemRecord | null {
695
+ const watchlistId = params.watchlistId ?? this.getDefaultWatchlist().id;
696
+ const existing = this.db
697
+ .prepare(
698
+ `SELECT wi.*
699
+ FROM watchlist_items wi
700
+ JOIN instruments i ON i.id = wi.instrument_id
701
+ WHERE wi.watchlist_id = ? AND i.symbol = ?
702
+ LIMIT 1`,
703
+ )
704
+ .get(watchlistId, symbol.trim().toUpperCase()) as WatchlistItemRow | undefined;
705
+ if (existing == null) return null;
706
+
707
+ const now = new Date().toISOString();
708
+ this.db
709
+ .prepare(
710
+ `UPDATE watchlist_items
711
+ SET target_price = ?, stop_price = ?, price_currency = ?, thesis = ?,
712
+ notes = ?, tags_json = ?, updated_at = ?
713
+ WHERE id = ?`,
714
+ )
715
+ .run(
716
+ params.targetPrice === undefined ? existing.target_price : params.targetPrice,
717
+ params.stopPrice === undefined ? existing.stop_price : params.stopPrice,
718
+ params.priceCurrency === undefined ? existing.price_currency : params.priceCurrency,
719
+ params.thesis === undefined ? existing.thesis : params.thesis,
720
+ params.notes === undefined ? existing.notes : params.notes,
721
+ params.tags == null ? existing.tags_json : JSON.stringify(params.tags),
722
+ now,
723
+ existing.id,
724
+ );
725
+ return this.getWatchlistItem(existing.id);
726
+ }
727
+
728
+ addPortfolioLot(params: {
729
+ instrument: InstrumentInput;
730
+ portfolioId?: number;
731
+ quantity: number;
732
+ avgCost: number;
733
+ currency: string;
734
+ openedAt?: string;
735
+ notes?: string;
736
+ source?: string;
737
+ sourceAccountRef?: string;
738
+ sourceLotId?: string;
739
+ sourceRowId?: string;
740
+ sourceMetadata?: unknown;
741
+ }): PortfolioLotRecord {
742
+ assertPositiveFinitePortfolioLotNumber(params.quantity, "quantity");
743
+ assertPositiveFinitePortfolioLotNumber(params.avgCost, "average cost");
744
+ const tx = this.db.transaction(() => {
745
+ const portfolioId = params.portfolioId ?? this.getDefaultPortfolio().id;
746
+ const instrument = this.upsertInstrument(params.instrument);
747
+ const now = new Date().toISOString();
748
+ const result = this.db
749
+ .prepare(
750
+ `INSERT INTO portfolio_lots (
751
+ portfolio_id, instrument_id, quantity, avg_cost, currency,
752
+ opened_at, notes, source, source_account_ref, source_lot_id,
753
+ source_row_id, source_metadata_json, created_at, updated_at
754
+ )
755
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
756
+ )
757
+ .run(
758
+ portfolioId,
759
+ instrument.id,
760
+ params.quantity,
761
+ params.avgCost,
762
+ params.currency.toUpperCase(),
763
+ params.openedAt ?? now,
764
+ params.notes ?? null,
765
+ normalizeNullable(params.source),
766
+ normalizeNullable(params.sourceAccountRef),
767
+ normalizeNullable(params.sourceLotId),
768
+ normalizeNullable(params.sourceRowId),
769
+ params.sourceMetadata == null ? null : JSON.stringify(params.sourceMetadata),
770
+ now,
771
+ now,
772
+ );
773
+ return Number(result.lastInsertRowid);
774
+ });
775
+
776
+ return this.getPortfolioLot(tx());
777
+ }
778
+
779
+ listPortfolioLots(portfolioId = this.getDefaultPortfolio().id): PortfolioLotRecord[] {
780
+ const rows = this.db
781
+ .prepare(
782
+ `SELECT pl.*, i.symbol, i.name, i.asset_type, i.exchange, i.currency AS instrument_currency
783
+ FROM portfolio_lots pl
784
+ JOIN instruments i ON i.id = pl.instrument_id
785
+ WHERE pl.portfolio_id = ?
786
+ ORDER BY pl.id`,
787
+ )
788
+ .all(portfolioId) as PortfolioLotRow[];
789
+ return rows.map(mapPortfolioLot);
790
+ }
791
+
792
+ removePortfolioLotsBySymbol(
793
+ symbol: string,
794
+ portfolioId = this.getDefaultPortfolio().id,
795
+ ): boolean {
796
+ const result = this.db
797
+ .prepare(
798
+ `DELETE FROM portfolio_lots
799
+ WHERE portfolio_id = ?
800
+ AND instrument_id IN (SELECT id FROM instruments WHERE symbol = ?)`,
801
+ )
802
+ .run(portfolioId, symbol.trim().toUpperCase());
803
+ return result.changes > 0;
804
+ }
805
+
806
+ removePortfolioLot(id: number): PortfolioLotRecord | null {
807
+ const existing = this.getPortfolioLotOrNull(id);
808
+ if (existing == null) return null;
809
+ this.db.prepare("DELETE FROM portfolio_lots WHERE id = ?").run(id);
810
+ return existing;
811
+ }
812
+
813
+ updatePortfolioLot(
814
+ id: number,
815
+ params: {
816
+ quantity?: number;
817
+ avgCost?: number;
818
+ currency?: string;
819
+ openedAt?: string;
820
+ notes?: string;
821
+ },
822
+ ): PortfolioLotRecord | null {
823
+ if (params.quantity != null) {
824
+ assertPositiveFinitePortfolioLotNumber(params.quantity, "quantity");
825
+ }
826
+ if (params.avgCost != null) {
827
+ assertPositiveFinitePortfolioLotNumber(params.avgCost, "average cost");
828
+ }
829
+ const existing = this.db.prepare("SELECT * FROM portfolio_lots WHERE id = ?").get(id) as
830
+ | PortfolioLotRow
831
+ | undefined;
832
+ if (existing == null) return null;
833
+
834
+ const now = new Date().toISOString();
835
+ this.db
836
+ .prepare(
837
+ `UPDATE portfolio_lots
838
+ SET quantity = ?, avg_cost = ?, currency = ?, opened_at = ?, notes = ?, updated_at = ?
839
+ WHERE id = ?`,
840
+ )
841
+ .run(
842
+ params.quantity ?? existing.quantity,
843
+ params.avgCost ?? existing.avg_cost,
844
+ params.currency == null ? existing.currency : params.currency.toUpperCase(),
845
+ params.openedAt ?? existing.opened_at,
846
+ params.notes ?? existing.notes,
847
+ now,
848
+ id,
849
+ );
850
+ return this.getPortfolioLot(id);
851
+ }
852
+
853
+ updatePortfolioLotsBySymbol(
854
+ symbol: string,
855
+ params: {
856
+ portfolioId?: number;
857
+ quantity?: number;
858
+ avgCost?: number;
859
+ currency?: string;
860
+ openedAt?: string;
861
+ notes?: string;
862
+ },
863
+ ): PortfolioLotRecord[] {
864
+ const portfolioId = params.portfolioId ?? this.getDefaultPortfolio().id;
865
+ const rows = this.db
866
+ .prepare(
867
+ `SELECT pl.*
868
+ FROM portfolio_lots pl
869
+ JOIN instruments i ON i.id = pl.instrument_id
870
+ WHERE pl.portfolio_id = ? AND i.symbol = ?
871
+ ORDER BY pl.id`,
872
+ )
873
+ .all(portfolioId, symbol.trim().toUpperCase()) as PortfolioLotRow[];
874
+ return rows.flatMap((row) => {
875
+ const updated = this.updatePortfolioLot(row.id, params);
876
+ return updated == null ? [] : [updated];
877
+ });
878
+ }
879
+
880
+ recordPrediction(params: {
881
+ instrument: InstrumentInput;
882
+ direction: PredictionDirection;
883
+ conviction: number;
884
+ entryPrice: number;
885
+ targetPrice?: number;
886
+ timeframeDays: number;
887
+ now?: Date;
888
+ }): PredictionRecord {
889
+ const tx = this.db.transaction(() => {
890
+ const instrument = this.upsertInstrument(params.instrument);
891
+ const opened = params.now ?? new Date();
892
+ const expires = new Date(opened);
893
+ expires.setDate(expires.getDate() + params.timeframeDays);
894
+ const nowIso = opened.toISOString();
895
+ const result = this.db
896
+ .prepare(
897
+ `INSERT INTO prediction_records (
898
+ instrument_id, direction, conviction, entry_price, target_price,
899
+ opened_at, expires_at, status, created_at, updated_at
900
+ )
901
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
902
+ )
903
+ .run(
904
+ instrument.id,
905
+ params.direction,
906
+ params.conviction,
907
+ params.entryPrice,
908
+ params.targetPrice ?? null,
909
+ nowIso,
910
+ expires.toISOString(),
911
+ nowIso,
912
+ nowIso,
913
+ );
914
+ return Number(result.lastInsertRowid);
915
+ });
916
+
917
+ return this.getPrediction(tx());
918
+ }
919
+
920
+ listPredictions(): PredictionRecord[] {
921
+ const rows = this.db
922
+ .prepare(
923
+ `SELECT pr.*, i.symbol
924
+ FROM prediction_records pr
925
+ JOIN instruments i ON i.id = pr.instrument_id
926
+ ORDER BY pr.opened_at, pr.id`,
927
+ )
928
+ .all() as PredictionRow[];
929
+ return rows.map(mapPrediction);
930
+ }
931
+
932
+ updatePredictionOutcome(params: {
933
+ id: number;
934
+ status: Exclude<PredictionStatus, "open">;
935
+ resolvedAt: string;
936
+ result: unknown;
937
+ }): PredictionRecord {
938
+ const now = new Date().toISOString();
939
+ this.db
940
+ .prepare(
941
+ `UPDATE prediction_records
942
+ SET status = ?, resolved_at = ?, result_json = ?, updated_at = ?
943
+ WHERE id = ?`,
944
+ )
945
+ .run(params.status, params.resolvedAt, JSON.stringify(params.result), now, params.id);
946
+ return this.getPrediction(params.id);
947
+ }
948
+
949
+ createAlertRule(params: {
950
+ scopeType: AlertScopeType;
951
+ scopeId?: number;
952
+ instrumentId?: number;
953
+ conditionType: string;
954
+ conditionVersion: number;
955
+ condition: unknown;
956
+ timeframe: string;
957
+ enabled?: boolean;
958
+ checkIntervalSeconds?: number;
959
+ nextCheckAt?: string;
960
+ cooldownSeconds?: number;
961
+ retriggerMode?: string;
962
+ }): AlertRuleRecord {
963
+ const now = new Date().toISOString();
964
+ const result = this.db
965
+ .prepare(
966
+ `INSERT INTO alert_rules (
967
+ scope_type, scope_id, instrument_id, condition_type, condition_version,
968
+ condition_json, timeframe, enabled, check_interval_seconds, next_check_at,
969
+ retrigger_mode, cooldown_seconds, created_at, updated_at
970
+ )
971
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
972
+ )
973
+ .run(
974
+ params.scopeType,
975
+ params.scopeId ?? null,
976
+ params.instrumentId ?? null,
977
+ params.conditionType,
978
+ params.conditionVersion,
979
+ JSON.stringify(params.condition),
980
+ params.timeframe,
981
+ params.enabled === false ? 0 : 1,
982
+ params.checkIntervalSeconds ?? null,
983
+ params.nextCheckAt ?? null,
984
+ params.retriggerMode ?? "recurring",
985
+ params.cooldownSeconds ?? null,
986
+ now,
987
+ now,
988
+ );
989
+ return this.getAlertRule(Number(result.lastInsertRowid));
990
+ }
991
+
992
+ listAlertRules(): AlertRuleRecord[] {
993
+ const rows = this.db
994
+ .prepare("SELECT * FROM alert_rules ORDER BY created_at, id")
995
+ .all() as AlertRuleRow[];
996
+ return rows.map(mapAlertRule);
997
+ }
998
+
999
+ setAlertRuleEnabled(id: number, enabled: boolean): AlertRuleRecord | null {
1000
+ const now = new Date().toISOString();
1001
+ const result = this.db
1002
+ .prepare(
1003
+ `UPDATE alert_rules
1004
+ SET enabled = ?, updated_at = ?
1005
+ WHERE id = ?`,
1006
+ )
1007
+ .run(enabled ? 1 : 0, now, id);
1008
+ if (result.changes === 0) return null;
1009
+ return this.getAlertRule(id);
1010
+ }
1011
+
1012
+ getInstrument(id: number): InstrumentRecord | null {
1013
+ const row = this.db.prepare("SELECT * FROM instruments WHERE id = ?").get(id) as
1014
+ | InstrumentRow
1015
+ | undefined;
1016
+ return row == null ? null : mapInstrument(row);
1017
+ }
1018
+
1019
+ upsertInstrumentRecord(input: InstrumentInput): InstrumentRecord {
1020
+ return mapInstrument(this.upsertInstrument(input));
1021
+ }
1022
+
1023
+ acquireAutomationRunnerLease(params: {
1024
+ ownerId: string;
1025
+ ownerKind: string;
1026
+ now?: string;
1027
+ ttlSeconds: number;
1028
+ }): AutomationRunnerLeaseResult {
1029
+ const now = params.now ?? new Date().toISOString();
1030
+ const nowMs = new Date(now).getTime();
1031
+ const expiresAt = new Date(nowMs + params.ttlSeconds * 1000).toISOString();
1032
+ const tx = this.db.transaction(() => {
1033
+ const current = this.db
1034
+ .prepare(
1035
+ "SELECT owner_id, owner_kind, acquired_at, heartbeat_at, expires_at FROM automation_runner_leases WHERE id = 1",
1036
+ )
1037
+ .get() as AutomationRunnerLeaseRow | undefined;
1038
+
1039
+ if (
1040
+ current != null &&
1041
+ current.owner_id !== params.ownerId &&
1042
+ new Date(current.expires_at).getTime() > nowMs
1043
+ ) {
1044
+ return { acquired: false, ...mapAutomationRunnerLease(current) };
1045
+ }
1046
+
1047
+ this.db
1048
+ .prepare(
1049
+ `INSERT INTO automation_runner_leases (
1050
+ id, owner_id, owner_kind, acquired_at, heartbeat_at, expires_at
1051
+ )
1052
+ VALUES (1, ?, ?, ?, ?, ?)
1053
+ ON CONFLICT(id) DO UPDATE SET
1054
+ owner_id = excluded.owner_id,
1055
+ owner_kind = excluded.owner_kind,
1056
+ acquired_at = excluded.acquired_at,
1057
+ heartbeat_at = excluded.heartbeat_at,
1058
+ expires_at = excluded.expires_at`,
1059
+ )
1060
+ .run(params.ownerId, params.ownerKind, now, now, expiresAt);
1061
+
1062
+ return {
1063
+ acquired: true,
1064
+ ownerId: params.ownerId,
1065
+ ownerKind: params.ownerKind,
1066
+ acquiredAt: now,
1067
+ heartbeatAt: now,
1068
+ expiresAt,
1069
+ };
1070
+ });
1071
+ return tx();
1072
+ }
1073
+
1074
+ getAutomationRunnerLease(now = new Date().toISOString()): AutomationRunnerLeaseRecord | null {
1075
+ const row = this.db
1076
+ .prepare(
1077
+ "SELECT owner_id, owner_kind, acquired_at, heartbeat_at, expires_at FROM automation_runner_leases WHERE id = 1",
1078
+ )
1079
+ .get() as AutomationRunnerLeaseRow | undefined;
1080
+ if (row == null) return null;
1081
+ if (new Date(row.expires_at).getTime() <= new Date(now).getTime()) return null;
1082
+ return mapAutomationRunnerLease(row);
1083
+ }
1084
+
1085
+ releaseAutomationRunnerLease(ownerId: string): boolean {
1086
+ const result = this.db
1087
+ .prepare("DELETE FROM automation_runner_leases WHERE id = 1 AND owner_id = ?")
1088
+ .run(ownerId);
1089
+ return result.changes > 0;
1090
+ }
1091
+
1092
+ startAlertCheckRun(params: {
1093
+ ownerId?: string | null;
1094
+ startedAt?: string;
1095
+ triggerType: string;
1096
+ }): AlertCheckRunRecord {
1097
+ const startedAt = params.startedAt ?? new Date().toISOString();
1098
+ const result = this.db
1099
+ .prepare(
1100
+ `INSERT INTO alert_check_runs (
1101
+ started_at, status, trigger_type, owner_id
1102
+ )
1103
+ VALUES (?, 'running', ?, ?)`,
1104
+ )
1105
+ .run(startedAt, params.triggerType, params.ownerId ?? null);
1106
+ return this.getAlertCheckRun(Number(result.lastInsertRowid));
1107
+ }
1108
+
1109
+ completeAlertCheckRun(
1110
+ id: number,
1111
+ params: {
1112
+ completedAt?: string;
1113
+ status: string;
1114
+ checkedCount: number;
1115
+ triggeredCount: number;
1116
+ unavailableCount: number;
1117
+ error?: unknown;
1118
+ providerStatus?: unknown;
1119
+ },
1120
+ ): AlertCheckRunRecord {
1121
+ const completedAt = params.completedAt ?? new Date().toISOString();
1122
+ this.db
1123
+ .prepare(
1124
+ `UPDATE alert_check_runs
1125
+ SET completed_at = ?,
1126
+ status = ?,
1127
+ checked_count = ?,
1128
+ triggered_count = ?,
1129
+ unavailable_count = ?,
1130
+ error_json = ?,
1131
+ provider_status_json = ?
1132
+ WHERE id = ?`,
1133
+ )
1134
+ .run(
1135
+ completedAt,
1136
+ params.status,
1137
+ params.checkedCount,
1138
+ params.triggeredCount,
1139
+ params.unavailableCount,
1140
+ params.error == null ? null : JSON.stringify(params.error),
1141
+ params.providerStatus == null ? null : JSON.stringify(params.providerStatus),
1142
+ id,
1143
+ );
1144
+ return this.getAlertCheckRun(id);
1145
+ }
1146
+
1147
+ getAlertCheckRun(id: number): AlertCheckRunRecord {
1148
+ const row = this.db.prepare("SELECT * FROM alert_check_runs WHERE id = ?").get(id) as
1149
+ | AlertCheckRunRow
1150
+ | undefined;
1151
+ if (row == null) throw new Error(`alert check run ${id} not found`);
1152
+ return mapAlertCheckRun(row);
1153
+ }
1154
+
1155
+ listAlertCheckRuns(): AlertCheckRunRecord[] {
1156
+ const rows = this.db
1157
+ .prepare("SELECT * FROM alert_check_runs ORDER BY started_at DESC, id DESC")
1158
+ .all() as AlertCheckRunRow[];
1159
+ return rows.map(mapAlertCheckRun);
1160
+ }
1161
+
1162
+ markStaleAutomationRunsLost(params: { now?: string; graceSeconds: number }): {
1163
+ alertCheckRuns: number;
1164
+ reportRuns: number;
1165
+ } {
1166
+ const now = params.now ?? new Date().toISOString();
1167
+ const cutoff = new Date(new Date(now).getTime() - params.graceSeconds * 1000).toISOString();
1168
+ const tx = this.db.transaction(() => {
1169
+ const alertResult = this.db
1170
+ .prepare(
1171
+ `UPDATE alert_check_runs
1172
+ SET status = 'lost',
1173
+ completed_at = COALESCE(completed_at, ?),
1174
+ error_json = COALESCE(error_json, ?)
1175
+ WHERE status = 'running' AND started_at < ?`,
1176
+ )
1177
+ .run(now, JSON.stringify({ message: "runner lease expired before completion" }), cutoff);
1178
+ const reportResult = this.db
1179
+ .prepare(
1180
+ `UPDATE report_runs
1181
+ SET status = 'lost',
1182
+ completed_at = COALESCE(completed_at, ?),
1183
+ errors_json = COALESCE(errors_json, ?)
1184
+ WHERE status = 'running' AND started_at < ?`,
1185
+ )
1186
+ .run(now, JSON.stringify(["runner lease expired before completion"]), cutoff);
1187
+ return {
1188
+ alertCheckRuns: alertResult.changes,
1189
+ reportRuns: reportResult.changes,
1190
+ };
1191
+ });
1192
+ return tx();
1193
+ }
1194
+
1195
+ recordNotificationEvent(params: {
1196
+ sourceType: string;
1197
+ sourceId?: number | null;
1198
+ severity: string;
1199
+ title: string;
1200
+ body: string;
1201
+ payload?: unknown;
1202
+ status?: string;
1203
+ createdAt?: string;
1204
+ }): NotificationEventRecord {
1205
+ const createdAt = params.createdAt ?? new Date().toISOString();
1206
+ const result = this.db
1207
+ .prepare(
1208
+ `INSERT INTO notification_events (
1209
+ source_type, source_id, severity, title, body, payload_json, status, created_at
1210
+ )
1211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1212
+ )
1213
+ .run(
1214
+ params.sourceType,
1215
+ params.sourceId ?? null,
1216
+ params.severity,
1217
+ params.title,
1218
+ params.body,
1219
+ params.payload == null ? null : JSON.stringify(params.payload),
1220
+ params.status ?? "unread",
1221
+ createdAt,
1222
+ );
1223
+ return this.getNotificationEvent(Number(result.lastInsertRowid));
1224
+ }
1225
+
1226
+ listNotificationEvents(): NotificationEventRecord[] {
1227
+ const rows = this.db
1228
+ .prepare("SELECT * FROM notification_events ORDER BY created_at DESC, id DESC")
1229
+ .all() as NotificationEventRow[];
1230
+ return rows.map(mapNotificationEvent);
1231
+ }
1232
+
1233
+ getNotificationEvent(id: number): NotificationEventRecord {
1234
+ const row = this.db.prepare("SELECT * FROM notification_events WHERE id = ?").get(id) as
1235
+ | NotificationEventRow
1236
+ | undefined;
1237
+ if (row == null) throw new Error(`notification event ${id} not found`);
1238
+ return mapNotificationEvent(row);
1239
+ }
1240
+
1241
+ acknowledgeNotificationEvent(
1242
+ id: number,
1243
+ acknowledgedAt = new Date().toISOString(),
1244
+ ): NotificationEventRecord {
1245
+ this.db
1246
+ .prepare(
1247
+ `UPDATE notification_events
1248
+ SET status = 'acknowledged',
1249
+ acknowledged_at = ?
1250
+ WHERE id = ?`,
1251
+ )
1252
+ .run(acknowledgedAt, id);
1253
+ return this.getNotificationEvent(id);
1254
+ }
1255
+
1256
+ recordNotificationDeliveryAttempt(params: {
1257
+ notificationEventId: number;
1258
+ channel: string;
1259
+ status: string;
1260
+ attemptedAt?: string;
1261
+ completedAt?: string | null;
1262
+ response?: unknown;
1263
+ error?: string | null;
1264
+ }): NotificationDeliveryAttemptRecord {
1265
+ const attemptedAt = params.attemptedAt ?? new Date().toISOString();
1266
+ const result = this.db
1267
+ .prepare(
1268
+ `INSERT INTO notification_delivery_attempts (
1269
+ notification_event_id, channel, status, attempted_at, completed_at,
1270
+ response_json, error
1271
+ )
1272
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1273
+ )
1274
+ .run(
1275
+ params.notificationEventId,
1276
+ params.channel,
1277
+ params.status,
1278
+ attemptedAt,
1279
+ params.completedAt ?? null,
1280
+ params.response == null ? null : JSON.stringify(params.response),
1281
+ params.error ?? null,
1282
+ );
1283
+ return this.getNotificationDeliveryAttempt(Number(result.lastInsertRowid));
1284
+ }
1285
+
1286
+ listNotificationDeliveryAttempts(
1287
+ notificationEventId?: number,
1288
+ ): NotificationDeliveryAttemptRecord[] {
1289
+ const rows =
1290
+ notificationEventId == null
1291
+ ? this.db
1292
+ .prepare(
1293
+ "SELECT * FROM notification_delivery_attempts ORDER BY attempted_at DESC, id DESC",
1294
+ )
1295
+ .all()
1296
+ : this.db
1297
+ .prepare(
1298
+ "SELECT * FROM notification_delivery_attempts WHERE notification_event_id = ? ORDER BY attempted_at DESC, id DESC",
1299
+ )
1300
+ .all(notificationEventId);
1301
+ return (rows as NotificationDeliveryAttemptRow[]).map(mapNotificationDeliveryAttempt);
1302
+ }
1303
+
1304
+ getNotificationDeliveryAttempt(id: number): NotificationDeliveryAttemptRecord {
1305
+ const row = this.db
1306
+ .prepare("SELECT * FROM notification_delivery_attempts WHERE id = ?")
1307
+ .get(id) as NotificationDeliveryAttemptRow | undefined;
1308
+ if (row == null) throw new Error(`notification delivery attempt ${id} not found`);
1309
+ return mapNotificationDeliveryAttempt(row);
1310
+ }
1311
+
1312
+ updateAlertObservation(params: {
1313
+ ruleId: number;
1314
+ observed: unknown;
1315
+ checkedAt?: string;
1316
+ triggeredAt?: string;
1317
+ }): AlertRuleRecord {
1318
+ const checkedAt = params.checkedAt ?? new Date().toISOString();
1319
+ this.db
1320
+ .prepare(
1321
+ `UPDATE alert_rules
1322
+ SET last_checked_at = ?,
1323
+ last_observed_json = ?,
1324
+ last_triggered_at = COALESCE(?, last_triggered_at),
1325
+ updated_at = ?
1326
+ WHERE id = ?`,
1327
+ )
1328
+ .run(
1329
+ checkedAt,
1330
+ JSON.stringify(params.observed),
1331
+ params.triggeredAt ?? null,
1332
+ checkedAt,
1333
+ params.ruleId,
1334
+ );
1335
+ return this.getAlertRule(params.ruleId);
1336
+ }
1337
+
1338
+ recordAlertEvent(params: {
1339
+ alertRuleId: number;
1340
+ instrumentId?: number | null;
1341
+ observedValue: unknown;
1342
+ status: string;
1343
+ message: string;
1344
+ triggeredAt?: string;
1345
+ }): AlertEventRecord {
1346
+ const triggeredAt = params.triggeredAt ?? new Date().toISOString();
1347
+ const result = this.db
1348
+ .prepare(
1349
+ `INSERT INTO alert_events (
1350
+ alert_rule_id, instrument_id, observed_value_json, triggered_at, status, message
1351
+ )
1352
+ VALUES (?, ?, ?, ?, ?, ?)`,
1353
+ )
1354
+ .run(
1355
+ params.alertRuleId,
1356
+ params.instrumentId ?? null,
1357
+ JSON.stringify(params.observedValue),
1358
+ triggeredAt,
1359
+ params.status,
1360
+ params.message,
1361
+ );
1362
+ return this.getAlertEvent(Number(result.lastInsertRowid));
1363
+ }
1364
+
1365
+ recordAlertCheckResult(params: {
1366
+ ruleId: number;
1367
+ observed: unknown;
1368
+ checkedAt: string;
1369
+ trigger?: {
1370
+ expectedPreviousValue: number | null;
1371
+ expectedLastTriggeredAt: string | null;
1372
+ instrumentId: number | null;
1373
+ message: string;
1374
+ triggeredAt: string;
1375
+ };
1376
+ }): { triggered: boolean; rule: AlertRuleRecord } {
1377
+ const tx = this.db.transaction(() => {
1378
+ const row = this.db.prepare("SELECT * FROM alert_rules WHERE id = ?").get(params.ruleId) as
1379
+ | AlertRuleRow
1380
+ | undefined;
1381
+ if (row == null) {
1382
+ throw new Error(`alert rule ${params.ruleId} not found`);
1383
+ }
1384
+
1385
+ const currentPrevious = lastObservedValueFromJson(row.last_observed_json);
1386
+ const currentLastTriggeredAt = row.last_triggered_at ?? null;
1387
+ const canTrigger =
1388
+ params.trigger != null &&
1389
+ currentPrevious === params.trigger.expectedPreviousValue &&
1390
+ currentLastTriggeredAt === params.trigger.expectedLastTriggeredAt;
1391
+
1392
+ if (canTrigger && params.trigger) {
1393
+ this.db
1394
+ .prepare(
1395
+ `INSERT INTO alert_events (
1396
+ alert_rule_id, instrument_id, observed_value_json, triggered_at, status, message
1397
+ )
1398
+ VALUES (?, ?, ?, ?, 'triggered', ?)`,
1399
+ )
1400
+ .run(
1401
+ params.ruleId,
1402
+ params.trigger.instrumentId,
1403
+ JSON.stringify(params.observed),
1404
+ params.trigger.triggeredAt,
1405
+ params.trigger.message,
1406
+ );
1407
+ }
1408
+
1409
+ this.db
1410
+ .prepare(
1411
+ `UPDATE alert_rules
1412
+ SET last_checked_at = ?,
1413
+ last_observed_json = ?,
1414
+ last_triggered_at = COALESCE(?, last_triggered_at),
1415
+ updated_at = ?
1416
+ WHERE id = ?`,
1417
+ )
1418
+ .run(
1419
+ params.checkedAt,
1420
+ JSON.stringify(params.observed),
1421
+ canTrigger && params.trigger ? params.trigger.triggeredAt : null,
1422
+ params.checkedAt,
1423
+ params.ruleId,
1424
+ );
1425
+
1426
+ return canTrigger;
1427
+ });
1428
+ const triggered = tx();
1429
+ return { triggered, rule: this.getAlertRule(params.ruleId) };
1430
+ }
1431
+
1432
+ recordAlertEvaluationResult(params: {
1433
+ ruleId: number;
1434
+ observed: unknown;
1435
+ checkedAt: string;
1436
+ conditionState: "true" | "false" | "unknown";
1437
+ trigger?: {
1438
+ instrumentId: number | null;
1439
+ title?: string;
1440
+ message: string;
1441
+ triggeredAt: string;
1442
+ observedAt: string;
1443
+ providerDataAt?: string | null;
1444
+ sourceProvider?: string | null;
1445
+ cacheStatus?: string;
1446
+ dataDelayMs?: number | null;
1447
+ triggerSource: string;
1448
+ dedupeKey: string;
1449
+ status?: string;
1450
+ };
1451
+ }): { triggered: boolean; event: AlertEventRecord | null; rule: AlertRuleRecord } {
1452
+ const tx = this.db.transaction(() => {
1453
+ const row = this.db.prepare("SELECT * FROM alert_rules WHERE id = ?").get(params.ruleId) as
1454
+ | AlertRuleRow
1455
+ | undefined;
1456
+ if (row == null) {
1457
+ throw new Error(`alert rule ${params.ruleId} not found`);
1458
+ }
1459
+
1460
+ const rearmed = params.conditionState === "false" && row.last_condition_state === "true";
1461
+ const nextArmCycleId = rearmed ? row.arm_cycle_id + 1 : row.arm_cycle_id;
1462
+ let eventId: number | null = null;
1463
+ const canInsertTrigger =
1464
+ params.trigger != null &&
1465
+ row.enabled === 1 &&
1466
+ row.status === "active" &&
1467
+ row.last_condition_state !== "true";
1468
+
1469
+ if (params.trigger != null && canInsertTrigger) {
1470
+ const result = this.db
1471
+ .prepare(
1472
+ `INSERT OR IGNORE INTO alert_events (
1473
+ alert_rule_id, instrument_id, observed_value_json, triggered_at,
1474
+ observed_at, provider_data_at, source_provider, cache_status,
1475
+ data_delay_ms, trigger_source, dedupe_key, status, message
1476
+ )
1477
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1478
+ )
1479
+ .run(
1480
+ params.ruleId,
1481
+ params.trigger.instrumentId,
1482
+ JSON.stringify(params.observed),
1483
+ params.trigger.triggeredAt,
1484
+ params.trigger.observedAt,
1485
+ params.trigger.providerDataAt ?? null,
1486
+ params.trigger.sourceProvider ?? null,
1487
+ params.trigger.cacheStatus ?? "live",
1488
+ params.trigger.dataDelayMs ?? null,
1489
+ params.trigger.triggerSource,
1490
+ params.trigger.dedupeKey,
1491
+ params.trigger.status ?? "triggered",
1492
+ params.trigger.message,
1493
+ );
1494
+
1495
+ if (result.changes > 0) {
1496
+ eventId = Number(result.lastInsertRowid);
1497
+ this.db
1498
+ .prepare(
1499
+ `INSERT INTO notification_events (
1500
+ source_type, source_id, severity, title, body, payload_json, status, created_at
1501
+ )
1502
+ VALUES ('alert_event', ?, 'warning', ?, ?, ?, 'unread', ?)`,
1503
+ )
1504
+ .run(
1505
+ eventId,
1506
+ params.trigger.title ?? "Alert triggered",
1507
+ params.trigger.message,
1508
+ JSON.stringify({
1509
+ ruleId: params.ruleId,
1510
+ observed: params.observed,
1511
+ sourceProvider: params.trigger.sourceProvider ?? null,
1512
+ providerDataAt: params.trigger.providerDataAt ?? null,
1513
+ cacheStatus: params.trigger.cacheStatus ?? "live",
1514
+ dataDelayMs: params.trigger.dataDelayMs ?? null,
1515
+ }),
1516
+ params.trigger.observedAt,
1517
+ );
1518
+ }
1519
+ }
1520
+
1521
+ const triggered = eventId != null;
1522
+ const completeOneShot = triggered && row.retrigger_mode === "once";
1523
+ this.db
1524
+ .prepare(
1525
+ `UPDATE alert_rules
1526
+ SET last_checked_at = ?,
1527
+ last_observed_json = ?,
1528
+ last_condition_state = ?,
1529
+ arm_cycle_id = ?,
1530
+ next_check_at = ?,
1531
+ last_triggered_at = COALESCE(?, last_triggered_at),
1532
+ enabled = CASE WHEN ? THEN 0 ELSE enabled END,
1533
+ status = CASE WHEN ? THEN 'completed' ELSE status END,
1534
+ updated_at = ?
1535
+ WHERE id = ?`,
1536
+ )
1537
+ .run(
1538
+ params.checkedAt,
1539
+ JSON.stringify(params.observed),
1540
+ params.conditionState,
1541
+ nextArmCycleId,
1542
+ nextAlertCheckAt(row, params.checkedAt),
1543
+ triggered && params.trigger ? params.trigger.triggeredAt : null,
1544
+ completeOneShot ? 1 : 0,
1545
+ completeOneShot ? 1 : 0,
1546
+ params.checkedAt,
1547
+ params.ruleId,
1548
+ );
1549
+
1550
+ return { triggered, eventId };
1551
+ });
1552
+ const result = tx();
1553
+ return {
1554
+ triggered: result.triggered,
1555
+ event: result.eventId == null ? null : this.getAlertEvent(result.eventId),
1556
+ rule: this.getAlertRule(params.ruleId),
1557
+ };
1558
+ }
1559
+
1560
+ recordAlertUnavailable(params: {
1561
+ ruleId: number;
1562
+ instrumentId?: number | null;
1563
+ reason: string;
1564
+ checkedAt: string;
1565
+ }): { event: AlertEventRecord; rule: AlertRuleRecord } {
1566
+ const tx = this.db.transaction(() => {
1567
+ const row = this.db.prepare("SELECT * FROM alert_rules WHERE id = ?").get(params.ruleId) as
1568
+ | AlertRuleRow
1569
+ | undefined;
1570
+ if (row == null) {
1571
+ throw new Error(`alert rule ${params.ruleId} not found`);
1572
+ }
1573
+
1574
+ const result = this.db
1575
+ .prepare(
1576
+ `INSERT INTO alert_events (
1577
+ alert_rule_id, instrument_id, observed_value_json, triggered_at, status, message
1578
+ )
1579
+ VALUES (?, ?, ?, ?, 'unavailable', ?)`,
1580
+ )
1581
+ .run(
1582
+ params.ruleId,
1583
+ params.instrumentId ?? null,
1584
+ JSON.stringify({ status: "unavailable", reason: params.reason, at: params.checkedAt }),
1585
+ params.checkedAt,
1586
+ `Alert unavailable: ${params.reason}`,
1587
+ );
1588
+
1589
+ this.db
1590
+ .prepare(
1591
+ `UPDATE alert_rules
1592
+ SET last_checked_at = ?,
1593
+ next_check_at = ?,
1594
+ updated_at = ?
1595
+ WHERE id = ?`,
1596
+ )
1597
+ .run(
1598
+ params.checkedAt,
1599
+ nextAlertCheckAt(row, params.checkedAt),
1600
+ params.checkedAt,
1601
+ params.ruleId,
1602
+ );
1603
+
1604
+ return Number(result.lastInsertRowid);
1605
+ });
1606
+ const eventId = tx();
1607
+ return { event: this.getAlertEvent(eventId), rule: this.getAlertRule(params.ruleId) };
1608
+ }
1609
+
1610
+ listAlertEvents(): AlertEventRecord[] {
1611
+ const rows = this.db
1612
+ .prepare("SELECT * FROM alert_events ORDER BY triggered_at, id")
1613
+ .all() as AlertEventRow[];
1614
+ return rows.map(mapAlertEvent);
1615
+ }
1616
+
1617
+ createReportTemplate(params: {
1618
+ name: string;
1619
+ reportType: string;
1620
+ cadence: string;
1621
+ timezone: string;
1622
+ localTime: string;
1623
+ config: unknown;
1624
+ enabled?: boolean;
1625
+ nextRunAt?: string;
1626
+ }): ReportTemplateRecord {
1627
+ const now = new Date().toISOString();
1628
+ const result = this.db
1629
+ .prepare(
1630
+ `INSERT INTO report_templates (
1631
+ name, report_type, cadence, timezone, local_time, config_json,
1632
+ enabled, next_run_at, created_at, updated_at
1633
+ )
1634
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1635
+ )
1636
+ .run(
1637
+ params.name,
1638
+ params.reportType,
1639
+ params.cadence,
1640
+ params.timezone,
1641
+ params.localTime,
1642
+ JSON.stringify(params.config),
1643
+ params.enabled === false ? 0 : 1,
1644
+ params.nextRunAt ?? null,
1645
+ now,
1646
+ now,
1647
+ );
1648
+ return this.getReportTemplate(Number(result.lastInsertRowid));
1649
+ }
1650
+
1651
+ updateReportTemplate(
1652
+ id: number,
1653
+ params: {
1654
+ name?: string;
1655
+ reportType?: string;
1656
+ cadence?: string;
1657
+ timezone?: string;
1658
+ localTime?: string;
1659
+ config?: unknown;
1660
+ enabled?: boolean;
1661
+ nextRunAt?: string | null;
1662
+ },
1663
+ ): ReportTemplateRecord {
1664
+ const existing = this.getReportTemplate(id);
1665
+ const now = new Date().toISOString();
1666
+ this.db
1667
+ .prepare(
1668
+ `UPDATE report_templates
1669
+ SET name = ?, report_type = ?, cadence = ?, timezone = ?, local_time = ?,
1670
+ config_json = ?, enabled = ?, next_run_at = ?, updated_at = ?
1671
+ WHERE id = ?`,
1672
+ )
1673
+ .run(
1674
+ params.name ?? existing.name,
1675
+ params.reportType ?? existing.reportType,
1676
+ params.cadence ?? existing.cadence,
1677
+ params.timezone ?? existing.timezone,
1678
+ params.localTime ?? existing.localTime,
1679
+ JSON.stringify(params.config ?? existing.configJson),
1680
+ params.enabled == null ? (existing.enabled ? 1 : 0) : params.enabled ? 1 : 0,
1681
+ params.nextRunAt === undefined ? existing.nextRunAt : params.nextRunAt,
1682
+ now,
1683
+ id,
1684
+ );
1685
+ return this.getReportTemplate(id);
1686
+ }
1687
+
1688
+ claimDueReportTemplateRun(
1689
+ id: number,
1690
+ params: {
1691
+ scheduledFor: string;
1692
+ nextRunAt: string;
1693
+ claimedAt?: string;
1694
+ },
1695
+ ): ReportTemplateRecord | null {
1696
+ const result = this.db
1697
+ .prepare(
1698
+ `UPDATE report_templates
1699
+ SET next_run_at = ?, updated_at = ?
1700
+ WHERE id = ? AND enabled = 1 AND next_run_at = ?`,
1701
+ )
1702
+ .run(params.nextRunAt, params.claimedAt ?? new Date().toISOString(), id, params.scheduledFor);
1703
+ return result.changes === 0 ? null : this.getReportTemplate(id);
1704
+ }
1705
+
1706
+ listReportTemplates(): ReportTemplateRecord[] {
1707
+ const rows = this.db
1708
+ .prepare("SELECT * FROM report_templates ORDER BY created_at, id")
1709
+ .all() as ReportTemplateRow[];
1710
+ return rows.map(mapReportTemplate);
1711
+ }
1712
+
1713
+ recordReportRun(params: {
1714
+ templateId?: number | null;
1715
+ startedAt?: string;
1716
+ completedAt?: string | null;
1717
+ status: string;
1718
+ triggerType?: string;
1719
+ scheduledFor?: string | null;
1720
+ ownerId?: string | null;
1721
+ artifactPath?: string | null;
1722
+ summary?: unknown;
1723
+ errors?: unknown;
1724
+ }): ReportRunRecord {
1725
+ const startedAt = params.startedAt ?? new Date().toISOString();
1726
+ const tx = this.db.transaction(() => {
1727
+ const result = this.db
1728
+ .prepare(
1729
+ `INSERT INTO report_runs (
1730
+ template_id, started_at, completed_at, status, trigger_type, scheduled_for,
1731
+ owner_id, artifact_path, summary_json, errors_json
1732
+ )
1733
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1734
+ )
1735
+ .run(
1736
+ params.templateId ?? null,
1737
+ startedAt,
1738
+ params.completedAt ?? null,
1739
+ params.status,
1740
+ params.triggerType ?? "manual",
1741
+ params.scheduledFor ?? null,
1742
+ params.ownerId ?? null,
1743
+ params.artifactPath ?? null,
1744
+ params.summary == null ? null : JSON.stringify(params.summary),
1745
+ params.errors == null ? null : JSON.stringify(params.errors),
1746
+ );
1747
+
1748
+ if (params.templateId != null) {
1749
+ this.db
1750
+ .prepare("UPDATE report_templates SET last_run_at = ?, updated_at = ? WHERE id = ?")
1751
+ .run(startedAt, startedAt, params.templateId);
1752
+ }
1753
+
1754
+ return Number(result.lastInsertRowid);
1755
+ });
1756
+ return this.getReportRun(tx());
1757
+ }
1758
+
1759
+ listReportRuns(): ReportRunRecord[] {
1760
+ const rows = this.db
1761
+ .prepare("SELECT * FROM report_runs ORDER BY started_at DESC, id DESC")
1762
+ .all() as ReportRunRow[];
1763
+ return rows.map(mapReportRun);
1764
+ }
1765
+
1766
+ recordImportBatch(params: {
1767
+ source: string;
1768
+ sourceLabel?: string;
1769
+ importedAt?: string;
1770
+ status: string;
1771
+ rawMetadata?: unknown;
1772
+ }): ImportBatchRecord {
1773
+ const importedAt = params.importedAt ?? new Date().toISOString();
1774
+ const result = this.db
1775
+ .prepare(
1776
+ `INSERT INTO import_batches (
1777
+ source, source_label, imported_at, status, raw_metadata_json
1778
+ )
1779
+ VALUES (?, ?, ?, ?, ?)`,
1780
+ )
1781
+ .run(
1782
+ normalizeNullable(params.source) ?? "unknown",
1783
+ normalizeNullable(params.sourceLabel),
1784
+ importedAt,
1785
+ params.status,
1786
+ params.rawMetadata == null ? null : JSON.stringify(params.rawMetadata),
1787
+ );
1788
+ return this.getImportBatch(Number(result.lastInsertRowid));
1789
+ }
1790
+
1791
+ recordImportRow(params: {
1792
+ batchId: number;
1793
+ rowType: string;
1794
+ sourceSymbol?: string;
1795
+ sourceRowId?: string;
1796
+ sourceAccountRef?: string;
1797
+ normalizedInstrumentId?: number | null;
1798
+ status: string;
1799
+ error?: string;
1800
+ sourceMetadata?: unknown;
1801
+ raw?: unknown;
1802
+ }): ImportRowRecord {
1803
+ const result = this.db
1804
+ .prepare(
1805
+ `INSERT INTO import_rows (
1806
+ batch_id, row_type, source_symbol, source_row_id, source_account_ref,
1807
+ normalized_instrument_id, status, error, source_metadata_json, raw_json
1808
+ )
1809
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1810
+ )
1811
+ .run(
1812
+ params.batchId,
1813
+ params.rowType,
1814
+ normalizeNullable(params.sourceSymbol),
1815
+ normalizeNullable(params.sourceRowId),
1816
+ normalizeNullable(params.sourceAccountRef),
1817
+ params.normalizedInstrumentId ?? null,
1818
+ params.status,
1819
+ normalizeNullable(params.error),
1820
+ params.sourceMetadata == null ? null : JSON.stringify(params.sourceMetadata),
1821
+ params.raw == null ? null : JSON.stringify(params.raw),
1822
+ );
1823
+ return this.getImportRow(Number(result.lastInsertRowid));
1824
+ }
1825
+
1826
+ private upsertInstrument(input: InstrumentInput): InstrumentRow {
1827
+ const symbol = input.symbol.trim().toUpperCase();
1828
+ const assetType = input.assetType.trim().toLowerCase();
1829
+ const provider = input.provider.trim().toLowerCase();
1830
+ const exchange = normalizeNullable(input.exchange);
1831
+ const now = new Date().toISOString();
1832
+ const resolvedAt = (input.resolvedAt ?? new Date()).toISOString();
1833
+
1834
+ const existing = this.db
1835
+ .prepare(
1836
+ `SELECT * FROM instruments
1837
+ WHERE provider = ?
1838
+ AND symbol = ?
1839
+ AND asset_type = ?
1840
+ AND IFNULL(exchange, '') = IFNULL(?, '')`,
1841
+ )
1842
+ .get(provider, symbol, assetType, exchange) as InstrumentRow | undefined;
1843
+
1844
+ if (existing) {
1845
+ this.db
1846
+ .prepare(
1847
+ `UPDATE instruments
1848
+ SET name = ?, currency = ?, provider_metadata_json = ?,
1849
+ last_resolved_at = ?, updated_at = ?
1850
+ WHERE id = ?`,
1851
+ )
1852
+ .run(
1853
+ normalizeNullable(input.name),
1854
+ normalizeNullable(input.currency)?.toUpperCase() ?? null,
1855
+ input.providerMetadata == null ? null : JSON.stringify(input.providerMetadata),
1856
+ resolvedAt,
1857
+ now,
1858
+ existing.id,
1859
+ );
1860
+ this.upsertInstrumentAliases(existing.id, input.aliases ?? []);
1861
+ return this.db
1862
+ .prepare("SELECT * FROM instruments WHERE id = ?")
1863
+ .get(existing.id) as InstrumentRow;
1864
+ }
1865
+
1866
+ const result = this.db
1867
+ .prepare(
1868
+ `INSERT INTO instruments (
1869
+ symbol, asset_type, name, exchange, currency, provider,
1870
+ provider_metadata_json, last_resolved_at, created_at, updated_at
1871
+ )
1872
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1873
+ )
1874
+ .run(
1875
+ symbol,
1876
+ assetType,
1877
+ normalizeNullable(input.name),
1878
+ exchange,
1879
+ normalizeNullable(input.currency)?.toUpperCase() ?? null,
1880
+ provider,
1881
+ input.providerMetadata == null ? null : JSON.stringify(input.providerMetadata),
1882
+ resolvedAt,
1883
+ now,
1884
+ now,
1885
+ );
1886
+ const instrumentId = Number(result.lastInsertRowid);
1887
+ this.upsertInstrumentAliases(instrumentId, input.aliases ?? []);
1888
+ return this.db
1889
+ .prepare("SELECT * FROM instruments WHERE id = ?")
1890
+ .get(instrumentId) as InstrumentRow;
1891
+ }
1892
+
1893
+ private upsertInstrumentAliases(instrumentId: number, aliases: InstrumentAliasInput[]): void {
1894
+ if (aliases.length === 0) return;
1895
+
1896
+ const now = new Date().toISOString();
1897
+ for (const alias of aliases) {
1898
+ const source = normalizeSource(alias.source);
1899
+ const sourceSymbol = normalizeSourceSymbol(alias.sourceSymbol);
1900
+ const sourceExchange = normalizeExchange(alias.sourceExchange);
1901
+ const sourceAssetType = normalizeAssetType(alias.sourceAssetType);
1902
+ const sourceId = normalizeNullable(alias.sourceId);
1903
+ const rawJson = alias.raw == null ? null : JSON.stringify(alias.raw);
1904
+ const existing =
1905
+ sourceId == null
1906
+ ? (this.db
1907
+ .prepare(
1908
+ `SELECT id, instrument_id FROM instrument_aliases
1909
+ WHERE source = ?
1910
+ AND source_symbol = ?
1911
+ AND IFNULL(source_exchange, '') = IFNULL(?, '')
1912
+ AND IFNULL(source_asset_type, '') = IFNULL(?, '')
1913
+ LIMIT 1`,
1914
+ )
1915
+ .get(source, sourceSymbol, sourceExchange, sourceAssetType) as
1916
+ | InstrumentAliasRow
1917
+ | undefined)
1918
+ : (this.db
1919
+ .prepare(
1920
+ `SELECT id, instrument_id FROM instrument_aliases
1921
+ WHERE source = ? AND source_id = ?
1922
+ LIMIT 1`,
1923
+ )
1924
+ .get(source, sourceId) as InstrumentAliasRow | undefined);
1925
+
1926
+ if (existing) {
1927
+ this.db
1928
+ .prepare(
1929
+ `UPDATE instrument_aliases
1930
+ SET instrument_id = ?, source_symbol = ?, source_exchange = ?,
1931
+ source_asset_type = ?, source_id = ?, raw_json = ?, updated_at = ?
1932
+ WHERE id = ?`,
1933
+ )
1934
+ .run(
1935
+ instrumentId,
1936
+ sourceSymbol,
1937
+ sourceExchange,
1938
+ sourceAssetType,
1939
+ sourceId,
1940
+ rawJson,
1941
+ now,
1942
+ existing.id,
1943
+ );
1944
+ continue;
1945
+ }
1946
+
1947
+ this.db
1948
+ .prepare(
1949
+ `INSERT INTO instrument_aliases (
1950
+ instrument_id, source, source_symbol, source_exchange,
1951
+ source_asset_type, source_id, raw_json, created_at, updated_at
1952
+ )
1953
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1954
+ )
1955
+ .run(
1956
+ instrumentId,
1957
+ source,
1958
+ sourceSymbol,
1959
+ sourceExchange,
1960
+ sourceAssetType,
1961
+ sourceId,
1962
+ rawJson,
1963
+ now,
1964
+ now,
1965
+ );
1966
+ }
1967
+ }
1968
+
1969
+ private getWatchlistItem(id: number): WatchlistItemRecord {
1970
+ const row = this.db
1971
+ .prepare(
1972
+ `SELECT wi.*, i.symbol, i.name, i.asset_type, i.exchange, i.currency
1973
+ FROM watchlist_items wi
1974
+ JOIN instruments i ON i.id = wi.instrument_id
1975
+ WHERE wi.id = ?`,
1976
+ )
1977
+ .get(id) as WatchlistItemRow;
1978
+ return mapWatchlistItem(row);
1979
+ }
1980
+
1981
+ private getPortfolioLot(id: number): PortfolioLotRecord {
1982
+ const row = this.getPortfolioLotOrNull(id);
1983
+ if (row == null) {
1984
+ throw new Error(`portfolio lot ${id} not found`);
1985
+ }
1986
+ return row;
1987
+ }
1988
+
1989
+ private getPortfolioLotOrNull(id: number): PortfolioLotRecord | null {
1990
+ const row = this.db
1991
+ .prepare(
1992
+ `SELECT pl.*, i.symbol, i.name, i.asset_type, i.exchange, i.currency AS instrument_currency
1993
+ FROM portfolio_lots pl
1994
+ JOIN instruments i ON i.id = pl.instrument_id
1995
+ WHERE pl.id = ?`,
1996
+ )
1997
+ .get(id) as PortfolioLotRow | undefined;
1998
+ return row == null ? null : mapPortfolioLot(row);
1999
+ }
2000
+
2001
+ private getPrediction(id: number): PredictionRecord {
2002
+ const row = this.db
2003
+ .prepare(
2004
+ `SELECT pr.*, i.symbol
2005
+ FROM prediction_records pr
2006
+ JOIN instruments i ON i.id = pr.instrument_id
2007
+ WHERE pr.id = ?`,
2008
+ )
2009
+ .get(id) as PredictionRow;
2010
+ return mapPrediction(row);
2011
+ }
2012
+
2013
+ getAlertRule(id: number): AlertRuleRecord {
2014
+ const row = this.db.prepare("SELECT * FROM alert_rules WHERE id = ?").get(id) as AlertRuleRow;
2015
+ return mapAlertRule(row);
2016
+ }
2017
+
2018
+ getAlertEvent(id: number): AlertEventRecord {
2019
+ const row = this.db.prepare("SELECT * FROM alert_events WHERE id = ?").get(id) as AlertEventRow;
2020
+ return mapAlertEvent(row);
2021
+ }
2022
+
2023
+ private getReportTemplate(id: number): ReportTemplateRecord {
2024
+ const row = this.db
2025
+ .prepare("SELECT * FROM report_templates WHERE id = ?")
2026
+ .get(id) as ReportTemplateRow;
2027
+ return mapReportTemplate(row);
2028
+ }
2029
+
2030
+ private getReportRun(id: number): ReportRunRecord {
2031
+ const row = this.db.prepare("SELECT * FROM report_runs WHERE id = ?").get(id) as ReportRunRow;
2032
+ return mapReportRun(row);
2033
+ }
2034
+
2035
+ private getImportBatch(id: number): ImportBatchRecord {
2036
+ const row = this.db
2037
+ .prepare("SELECT * FROM import_batches WHERE id = ?")
2038
+ .get(id) as ImportBatchRow;
2039
+ return mapImportBatch(row);
2040
+ }
2041
+
2042
+ private getImportRow(id: number): ImportRowRecord {
2043
+ const row = this.db.prepare("SELECT * FROM import_rows WHERE id = ?").get(id) as ImportRowRow;
2044
+ return mapImportRow(row);
2045
+ }
2046
+ }
2047
+
2048
+ function mapCollection(row: WatchlistRow): CollectionRecord {
2049
+ return {
2050
+ id: row.id,
2051
+ name: row.name,
2052
+ isDefault: row.is_default === 1,
2053
+ createdAt: row.created_at,
2054
+ updatedAt: row.updated_at,
2055
+ };
2056
+ }
2057
+
2058
+ function mapInstrument(row: InstrumentRow): InstrumentRecord {
2059
+ return {
2060
+ id: row.id,
2061
+ symbol: row.symbol,
2062
+ assetType: row.asset_type,
2063
+ name: row.name,
2064
+ exchange: row.exchange,
2065
+ currency: row.currency,
2066
+ provider: row.provider,
2067
+ createdAt: row.created_at,
2068
+ updatedAt: row.updated_at,
2069
+ };
2070
+ }
2071
+
2072
+ function mapWatchlistItem(row: WatchlistItemRow): WatchlistItemRecord {
2073
+ return {
2074
+ id: row.id,
2075
+ watchlistId: row.watchlist_id,
2076
+ instrumentId: row.instrument_id,
2077
+ symbol: row.symbol,
2078
+ name: row.name,
2079
+ assetType: row.asset_type,
2080
+ exchange: row.exchange,
2081
+ currency: row.currency,
2082
+ targetPrice: row.target_price,
2083
+ stopPrice: row.stop_price,
2084
+ priceCurrency: row.price_currency,
2085
+ thesis: row.thesis,
2086
+ notes: row.notes,
2087
+ tags: row.tags_json == null ? null : (JSON.parse(row.tags_json) as string[]),
2088
+ source: row.source,
2089
+ sourceRowId: row.source_row_id,
2090
+ sourceMetadata: row.source_metadata_json == null ? null : JSON.parse(row.source_metadata_json),
2091
+ createdAt: row.created_at,
2092
+ updatedAt: row.updated_at,
2093
+ };
2094
+ }
2095
+
2096
+ function mapPortfolioLot(row: PortfolioLotRow): PortfolioLotRecord {
2097
+ return {
2098
+ id: row.id,
2099
+ portfolioId: row.portfolio_id,
2100
+ instrumentId: row.instrument_id,
2101
+ symbol: row.symbol,
2102
+ name: row.name,
2103
+ assetType: row.asset_type,
2104
+ exchange: row.exchange,
2105
+ instrumentCurrency: row.instrument_currency,
2106
+ quantity: row.quantity,
2107
+ avgCost: row.avg_cost,
2108
+ currency: row.currency,
2109
+ openedAt: row.opened_at,
2110
+ notes: row.notes,
2111
+ source: row.source,
2112
+ sourceAccountRef: row.source_account_ref,
2113
+ sourceLotId: row.source_lot_id,
2114
+ sourceRowId: row.source_row_id,
2115
+ sourceMetadata: row.source_metadata_json == null ? null : JSON.parse(row.source_metadata_json),
2116
+ createdAt: row.created_at,
2117
+ updatedAt: row.updated_at,
2118
+ };
2119
+ }
2120
+
2121
+ function mapPrediction(row: PredictionRow): PredictionRecord {
2122
+ return {
2123
+ id: row.id,
2124
+ instrumentId: row.instrument_id,
2125
+ symbol: row.symbol,
2126
+ direction: row.direction,
2127
+ conviction: row.conviction,
2128
+ entryPrice: row.entry_price,
2129
+ targetPrice: row.target_price,
2130
+ openedAt: row.opened_at,
2131
+ expiresAt: row.expires_at,
2132
+ status: row.status,
2133
+ resolvedAt: row.resolved_at,
2134
+ resultJson: row.result_json,
2135
+ createdAt: row.created_at,
2136
+ updatedAt: row.updated_at,
2137
+ };
2138
+ }
2139
+
2140
+ function mapAlertRule(row: AlertRuleRow): AlertRuleRecord {
2141
+ return {
2142
+ id: row.id,
2143
+ scopeType: row.scope_type,
2144
+ scopeId: row.scope_id,
2145
+ instrumentId: row.instrument_id,
2146
+ conditionType: row.condition_type,
2147
+ conditionVersion: row.condition_version,
2148
+ conditionJson: JSON.parse(row.condition_json),
2149
+ timeframe: row.timeframe,
2150
+ enabled: row.enabled === 1,
2151
+ checkIntervalSeconds: row.check_interval_seconds,
2152
+ nextCheckAt: row.next_check_at,
2153
+ lastCheckedAt: row.last_checked_at,
2154
+ lastObservedJson: row.last_observed_json == null ? null : JSON.parse(row.last_observed_json),
2155
+ status: row.status,
2156
+ retriggerMode: row.retrigger_mode,
2157
+ lastConditionState: row.last_condition_state,
2158
+ ruleRevision: row.rule_revision,
2159
+ armCycleId: row.arm_cycle_id,
2160
+ cooldownSeconds: row.cooldown_seconds,
2161
+ lastTriggeredAt: row.last_triggered_at,
2162
+ createdAt: row.created_at,
2163
+ updatedAt: row.updated_at,
2164
+ };
2165
+ }
2166
+
2167
+ function mapAlertEvent(row: AlertEventRow): AlertEventRecord {
2168
+ return {
2169
+ id: row.id,
2170
+ alertRuleId: row.alert_rule_id,
2171
+ instrumentId: row.instrument_id,
2172
+ observedValueJson: row.observed_value_json == null ? null : JSON.parse(row.observed_value_json),
2173
+ triggeredAt: row.triggered_at,
2174
+ observedAt: row.observed_at,
2175
+ providerDataAt: row.provider_data_at,
2176
+ sourceProvider: row.source_provider,
2177
+ cacheStatus: row.cache_status,
2178
+ dataDelayMs: row.data_delay_ms,
2179
+ triggerSource: row.trigger_source,
2180
+ dedupeKey: row.dedupe_key,
2181
+ status: row.status,
2182
+ message: row.message,
2183
+ };
2184
+ }
2185
+
2186
+ function mapAutomationRunnerLease(row: AutomationRunnerLeaseRow): AutomationRunnerLeaseRecord {
2187
+ return {
2188
+ ownerId: row.owner_id,
2189
+ ownerKind: row.owner_kind,
2190
+ acquiredAt: row.acquired_at,
2191
+ heartbeatAt: row.heartbeat_at,
2192
+ expiresAt: row.expires_at,
2193
+ };
2194
+ }
2195
+
2196
+ function mapAlertCheckRun(row: AlertCheckRunRow): AlertCheckRunRecord {
2197
+ return {
2198
+ id: row.id,
2199
+ startedAt: row.started_at,
2200
+ completedAt: row.completed_at,
2201
+ status: row.status,
2202
+ triggerType: row.trigger_type,
2203
+ checkedCount: row.checked_count,
2204
+ triggeredCount: row.triggered_count,
2205
+ unavailableCount: row.unavailable_count,
2206
+ ownerId: row.owner_id,
2207
+ errorJson: row.error_json == null ? null : JSON.parse(row.error_json),
2208
+ providerStatusJson:
2209
+ row.provider_status_json == null ? null : JSON.parse(row.provider_status_json),
2210
+ };
2211
+ }
2212
+
2213
+ function mapNotificationEvent(row: NotificationEventRow): NotificationEventRecord {
2214
+ return {
2215
+ id: row.id,
2216
+ sourceType: row.source_type,
2217
+ sourceId: row.source_id,
2218
+ severity: row.severity,
2219
+ title: row.title,
2220
+ body: row.body,
2221
+ payloadJson: row.payload_json == null ? null : JSON.parse(row.payload_json),
2222
+ status: row.status,
2223
+ createdAt: row.created_at,
2224
+ acknowledgedAt: row.acknowledged_at,
2225
+ };
2226
+ }
2227
+
2228
+ function mapNotificationDeliveryAttempt(
2229
+ row: NotificationDeliveryAttemptRow,
2230
+ ): NotificationDeliveryAttemptRecord {
2231
+ return {
2232
+ id: row.id,
2233
+ notificationEventId: row.notification_event_id,
2234
+ channel: row.channel,
2235
+ status: row.status,
2236
+ attemptedAt: row.attempted_at,
2237
+ completedAt: row.completed_at,
2238
+ responseJson: row.response_json == null ? null : JSON.parse(row.response_json),
2239
+ error: row.error,
2240
+ };
2241
+ }
2242
+
2243
+ function nextAlertCheckAt(row: AlertRuleRow, checkedAt: string): string | null {
2244
+ if (row.check_interval_seconds == null) return null;
2245
+ const checkedAtMs = new Date(checkedAt).getTime();
2246
+ if (!Number.isFinite(checkedAtMs)) return null;
2247
+ return new Date(checkedAtMs + row.check_interval_seconds * 1000).toISOString();
2248
+ }
2249
+
2250
+ function mapReportTemplate(row: ReportTemplateRow): ReportTemplateRecord {
2251
+ return {
2252
+ id: row.id,
2253
+ name: row.name,
2254
+ reportType: row.report_type,
2255
+ cadence: row.cadence,
2256
+ timezone: row.timezone,
2257
+ localTime: row.local_time,
2258
+ configJson: JSON.parse(row.config_json),
2259
+ enabled: row.enabled === 1,
2260
+ lastRunAt: row.last_run_at,
2261
+ nextRunAt: row.next_run_at,
2262
+ createdAt: row.created_at,
2263
+ updatedAt: row.updated_at,
2264
+ };
2265
+ }
2266
+
2267
+ function mapReportRun(row: ReportRunRow): ReportRunRecord {
2268
+ return {
2269
+ id: row.id,
2270
+ templateId: row.template_id,
2271
+ startedAt: row.started_at,
2272
+ completedAt: row.completed_at,
2273
+ status: row.status,
2274
+ triggerType: row.trigger_type,
2275
+ scheduledFor: row.scheduled_for,
2276
+ ownerId: row.owner_id,
2277
+ artifactPath: row.artifact_path,
2278
+ summaryJson: row.summary_json == null ? null : JSON.parse(row.summary_json),
2279
+ errorsJson: row.errors_json == null ? null : JSON.parse(row.errors_json),
2280
+ };
2281
+ }
2282
+
2283
+ function mapImportBatch(row: ImportBatchRow): ImportBatchRecord {
2284
+ return {
2285
+ id: row.id,
2286
+ source: row.source,
2287
+ sourceLabel: row.source_label,
2288
+ importedAt: row.imported_at,
2289
+ status: row.status,
2290
+ rawMetadata: row.raw_metadata_json == null ? null : JSON.parse(row.raw_metadata_json),
2291
+ };
2292
+ }
2293
+
2294
+ function mapImportRow(row: ImportRowRow): ImportRowRecord {
2295
+ return {
2296
+ id: row.id,
2297
+ batchId: row.batch_id,
2298
+ rowType: row.row_type,
2299
+ sourceSymbol: row.source_symbol,
2300
+ sourceRowId: row.source_row_id,
2301
+ sourceAccountRef: row.source_account_ref,
2302
+ normalizedInstrumentId: row.normalized_instrument_id,
2303
+ status: row.status,
2304
+ error: row.error,
2305
+ sourceMetadata: row.source_metadata_json == null ? null : JSON.parse(row.source_metadata_json),
2306
+ raw: row.raw_json == null ? null : JSON.parse(row.raw_json),
2307
+ };
2308
+ }
2309
+
2310
+ function normalizeNullable(value: string | null | undefined): string | null {
2311
+ const normalized = value?.trim();
2312
+ return normalized ? normalized : null;
2313
+ }
2314
+
2315
+ function assertPositiveFinitePortfolioLotNumber(
2316
+ value: number,
2317
+ label: "quantity" | "average cost",
2318
+ ): void {
2319
+ if (!Number.isFinite(value) || value <= 0) {
2320
+ throw new Error(`Portfolio lot ${label} must be a positive finite number.`);
2321
+ }
2322
+ }
2323
+
2324
+ function lastObservedValueFromJson(value: string | null): number | null {
2325
+ if (value == null) return null;
2326
+ const parsed = JSON.parse(value) as { value?: unknown } | null;
2327
+ return typeof parsed?.value === "number" ? parsed.value : null;
2328
+ }
2329
+
2330
+ function normalizeSource(value: string): string {
2331
+ return value.trim().toLowerCase();
2332
+ }
2333
+
2334
+ function normalizeSourceSymbol(value: string): string {
2335
+ return value.trim().toUpperCase();
2336
+ }
2337
+
2338
+ function normalizeExchange(value: string | null | undefined): string | null {
2339
+ return normalizeNullable(value)?.toUpperCase() ?? null;
2340
+ }
2341
+
2342
+ function normalizeAssetType(value: string | null | undefined): string | null {
2343
+ return normalizeNullable(value)?.toLowerCase() ?? null;
2344
+ }