marketmyna 0.2.0__py3-none-any.whl

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 (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
marketmyna/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ MarketMyna - Investment Analysis Platform
3
+ A comprehensive tool for stock market analysis, portfolio management,
4
+ and investment insights across US, Global, Indian markets, and Crypto.
5
+ """
6
+
7
+ __version__ = "0.2.0"
8
+ __author__ = "MarketMyna Team"
9
+
10
+ # Every process that imports the app (API, scheduled run, report workers) masks
11
+ # secrets such as the Telegram bot token in its logs.
12
+ from .utils import log_redact as _log_redact # noqa: E402
13
+
14
+ _log_redact.install()
@@ -0,0 +1,8 @@
1
+ """LLM-synthesis agent pipeline: technical/risk agents call MarketMyna's
2
+ own analysis modules as deterministic tools, an LLM backend narrates
3
+ each result, and a decision agent synthesizes everything into a report.
4
+
5
+ See orchestrator.py for the pipeline itself, context.py for the shared
6
+ state agents pass between each other, and dashboard.py for the final
7
+ report shape.
8
+ """
@@ -0,0 +1,543 @@
1
+ """The three built agents: technical, risk, decision.
2
+
3
+ Each agent's `run(ctx, backend)` follows the same shape: pull/compute
4
+ deterministic data via MarketMyna's own analysis modules (never
5
+ reimplemented here), hand a compact structured summary of that to the
6
+ LLM backend for narration, and return an AgentOpinion. The *signal*
7
+ each agent reports is derived from the deterministic computation, not
8
+ parsed out of the LLM's free text - the LLM's job is narration, not
9
+ being the source of truth for a value code can already compute exactly.
10
+
11
+ DecisionAgent is the one exception: it has no tools of its own (matches
12
+ the reference's decision agent having tool_names=[]) and its whole job
13
+ IS to produce a decision, so its output does require parsing the LLM's
14
+ response - done defensively, with a safe "hold" fallback on anything
15
+ that doesn't parse.
16
+
17
+ Scope cut: no IntelAgent (news search + risk-event detection) - that
18
+ needs a news-search API/tool that isn't configured. RiskAgent covers a
19
+ narrower, purely data-derived notion of risk (valuation extremes,
20
+ volatility, drawdown) instead of news-driven risk events.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import re
28
+ from typing import Callable, Optional
29
+
30
+ import pandas as pd
31
+
32
+ from ..analysis.fundamental import FundamentalAnalyzer
33
+ from ..analysis.technical import Signal, TechnicalAnalyzer
34
+ from ..core.compliance import guard_prompt, outlook
35
+ from ..core.constants import Markets
36
+ from ..data.china import ChinaAFetcher, HongKongFetcher
37
+ from ..data.crypto import CryptoDataFetcher
38
+ from ..data.india import IndiaDataFetcher
39
+ from ..data.stocks import StockDataFetcher
40
+ from ..llm.protocol import GenerationBackend, GenerationConfig
41
+ from .context import AgentContext, AgentOpinion
42
+ from .skills import Skill
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # Same fetcher-per-market pattern as marketmyna/api/v1/endpoints/stocks.py.
47
+ _FETCHERS = {
48
+ Markets.US: StockDataFetcher(),
49
+ Markets.GLOBAL: StockDataFetcher(),
50
+ Markets.INDIA: IndiaDataFetcher(),
51
+ Markets.CRYPTO: CryptoDataFetcher(),
52
+ Markets.CHINA_A: ChinaAFetcher(),
53
+ Markets.HONG_KONG: HongKongFetcher(),
54
+ }
55
+
56
+ _TREND_SIGNAL_MAP = {
57
+ Signal.STRONG_BUY: "bullish",
58
+ Signal.BUY: "bullish",
59
+ Signal.NEUTRAL: "neutral",
60
+ Signal.SELL: "bearish",
61
+ Signal.STRONG_SELL: "bearish",
62
+ }
63
+
64
+
65
+ def _resolve_fetcher(market: str):
66
+ try:
67
+ return _FETCHERS[Markets(market.lower())]
68
+ except (KeyError, ValueError) as exc:
69
+ raise ValueError(f"Unknown market '{market}'") from exc
70
+
71
+
72
+ # Appended to every agent's instructions: the app computes the numbers,
73
+ # the model only explains them - and must keep facts and opinion apart.
74
+ GUARDRAIL = (
75
+ " Use only the figures given in the input; never invent or recompute prices, ratios or "
76
+ "dates. Describe outlooks as possibilities with their reasons, not as certainties, and "
77
+ "do not present the output as personalised investment advice."
78
+ )
79
+
80
+
81
+ def _scope(ctx: AgentContext, stage: str) -> str:
82
+ """Cache scope for one stage of one stock's analysis: the AI's wording is
83
+ reused for the cache's lifetime (marketmyna/llm/store.py) even as the live
84
+ numbers in the prompt move, so a stock opened again, or booked by many
85
+ users, costs no new AI call."""
86
+ parts = [stage, ctx.symbol.upper(), ctx.market, getattr(ctx, "mode", ""), getattr(ctx, "area", None) or ""]
87
+ return "|".join(["agent", *parts])
88
+
89
+
90
+ def _call_llm(backend: GenerationBackend, system_prompt: str, prompt: str, scope: Optional[str] = None) -> str:
91
+ """Every agent's one LLM call, unified so error handling/config is in
92
+ one place. Raises GenerationError on failure - callers don't catch it
93
+ themselves, the orchestrator decides critical-vs-not per stage."""
94
+ result = backend.generate(prompt, GenerationConfig(cache_scope=scope), system_prompt=system_prompt + GUARDRAIL)
95
+ return result.text.strip()
96
+
97
+
98
+ def _narrate(backend: GenerationBackend, system_prompt: str, prompt: str, fallback: str,
99
+ scope: Optional[str] = None) -> str:
100
+ """LLM commentary for a stage whose numbers are computed without AI
101
+ (technical, risk). If the LLM is missing or failing, the stage still
102
+ succeeds with a rule-based sentence instead of taking the whole
103
+ pipeline down - only the decision stage truly needs the model."""
104
+ try:
105
+ # Commentary is a "level 1" job: use the fast/cheap model when one
106
+ # is configured (the backend falls back to the main chain itself).
107
+ return _call_llm(getattr(backend, "fast", backend), system_prompt, prompt, scope)
108
+ except Exception as exc:
109
+ logger.info("LLM commentary unavailable (%s); using rule-based summary", exc)
110
+ return fallback
111
+
112
+
113
+ class TechnicalAgent:
114
+ agent_name = "technical"
115
+
116
+ def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
117
+ fetcher = _resolve_fetcher(ctx.market)
118
+
119
+ ctx.quote = fetcher.get_quote(ctx.symbol)
120
+ ctx.history = fetcher.get_history(ctx.symbol, period="1y")
121
+
122
+ analyzer = TechnicalAnalyzer(ctx.history)
123
+ raw_summary = analyzer.get_summary()
124
+ # get_summary()'s overall_signal is a Signal enum instance, not
125
+ # JSON-safe - normalize before it goes anywhere near a prompt or
126
+ # a dashboard payload.
127
+ overall_signal: Signal = raw_summary["overall_signal"]
128
+ ctx.technical_summary = {
129
+ "overall_signal": overall_signal.value,
130
+ "buy_signals": raw_summary["buy_signals"],
131
+ "sell_signals": raw_summary["sell_signals"],
132
+ "neutral_signals": raw_summary["neutral_signals"],
133
+ "indicators": raw_summary["indicators"],
134
+ }
135
+
136
+ total = max(
137
+ ctx.technical_summary["buy_signals"]
138
+ + ctx.technical_summary["sell_signals"]
139
+ + ctx.technical_summary["neutral_signals"],
140
+ 1,
141
+ )
142
+ dominant = max(
143
+ ctx.technical_summary["buy_signals"],
144
+ ctx.technical_summary["sell_signals"],
145
+ ctx.technical_summary["neutral_signals"],
146
+ )
147
+ confidence = round(dominant / total, 2)
148
+
149
+ system_prompt = (
150
+ "You are a technical analysis assistant for a stock research tool. "
151
+ "Given a structured summary of technical indicators, write a concise "
152
+ "2-4 sentence opinion on the current trend and momentum. Do not "
153
+ "restate raw numbers verbatim, interpret them."
154
+ )
155
+ system_prompt = guard_prompt(system_prompt)
156
+ prompt = (
157
+ f"Symbol: {ctx.symbol} ({ctx.market})\n"
158
+ f"Current price: {ctx.quote.get('price')}\n"
159
+ f"Technical summary: {json.dumps(ctx.technical_summary)}"
160
+ )
161
+ # Without a model: the indicator values only, no verdict words.
162
+ fallback = "Indicator readings: " + ", ".join(
163
+ f"{name} {info['value']:.2f}" if isinstance(info.get("value"), (int, float)) else name
164
+ for name, info in list(ctx.technical_summary["indicators"].items())[:6]) + "."
165
+ narrative = _narrate(backend, system_prompt, prompt, fallback, scope=_scope(ctx, self.agent_name))
166
+
167
+ return AgentOpinion(
168
+ agent_name=self.agent_name,
169
+ summary=narrative,
170
+ signal=_TREND_SIGNAL_MAP.get(overall_signal, "neutral"),
171
+ confidence=confidence,
172
+ details=ctx.technical_summary,
173
+ )
174
+
175
+
176
+ def _max_drawdown(close: pd.Series) -> float:
177
+ running_max = close.cummax()
178
+ drawdown = (close - running_max) / running_max
179
+ return float(drawdown.min())
180
+
181
+
182
+ def _annualized_volatility(close: pd.Series) -> float:
183
+ returns = close.pct_change().dropna()
184
+ if returns.empty:
185
+ return 0.0
186
+ return float(returns.std() * (252 ** 0.5))
187
+
188
+
189
+ class RiskAgent:
190
+ agent_name = "risk"
191
+
192
+ def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
193
+ fetcher = _resolve_fetcher(ctx.market)
194
+
195
+ # Reuse history/quote TechnicalAgent already fetched when this
196
+ # runs after it in the chain; only fetch if run standalone.
197
+ if ctx.history is None:
198
+ ctx.history = fetcher.get_history(ctx.symbol, period="1y")
199
+ if ctx.quote is None:
200
+ ctx.quote = fetcher.get_quote(ctx.symbol)
201
+
202
+ volatility = _annualized_volatility(ctx.history["close"])
203
+ max_drawdown = _max_drawdown(ctx.history["close"])
204
+
205
+ fundamental_summary = None
206
+ try:
207
+ info = fetcher.get_info(ctx.symbol)
208
+ ctx.info = info
209
+ fundamental_summary = FundamentalAnalyzer(info).get_summary()
210
+ ctx.fundamental_summary = fundamental_summary
211
+ except Exception:
212
+ # Fundamentals aren't available/meaningful for every market
213
+ # (e.g. crypto) - risk assessment still works from price
214
+ # action alone.
215
+ pass
216
+
217
+ risk_level = "low"
218
+ if volatility > 0.5 or max_drawdown < -0.35:
219
+ risk_level = "high"
220
+ elif volatility > 0.25 or max_drawdown < -0.2:
221
+ risk_level = "medium"
222
+
223
+ ctx.risk_metrics = {
224
+ "volatility": round(volatility, 4),
225
+ "max_drawdown": round(max_drawdown, 4),
226
+ "risk_level": risk_level,
227
+ }
228
+
229
+ system_prompt = (
230
+ "You are a risk analyst for a stock research tool. Given price-risk "
231
+ "statistics and (if available) fundamental valuation data, write a "
232
+ "concise 2-3 sentence risk commentary. Be specific about what is "
233
+ "driving the risk level, not generic."
234
+ )
235
+ system_prompt = guard_prompt(system_prompt)
236
+ prompt_parts = [
237
+ f"Symbol: {ctx.symbol} ({ctx.market})",
238
+ f"Annualized volatility: {volatility:.2%}",
239
+ f"Max drawdown (lookback): {max_drawdown:.2%}",
240
+ f"Computed risk level: {risk_level}",
241
+ ]
242
+ if fundamental_summary:
243
+ prompt_parts.append(
244
+ f"Fundamental valuation: {fundamental_summary['valuation']['status']}, "
245
+ f"overall score {fundamental_summary['overall_score']}/100"
246
+ )
247
+ fallback = (
248
+ f"Annualized volatility {volatility:.0%} and max drawdown {max_drawdown:.0%} "
249
+ f"over the past year put this at {risk_level} risk."
250
+ )
251
+ narrative = _narrate(backend, system_prompt, "\n".join(prompt_parts), fallback,
252
+ scope=_scope(ctx, self.agent_name))
253
+
254
+ return AgentOpinion(
255
+ agent_name=self.agent_name,
256
+ summary=narrative,
257
+ signal=None,
258
+ confidence=None,
259
+ details=ctx.risk_metrics,
260
+ )
261
+
262
+
263
+ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
264
+
265
+
266
+ class NewsAgent:
267
+ """Collects recent headlines and reads their tone, so the decision
268
+ reflects events (results, deals, downgrades) and not just charts."""
269
+
270
+ agent_name = "news"
271
+
272
+ def __init__(self, search: Optional[Callable[..., list]] = None):
273
+ self._search = search
274
+
275
+ def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
276
+ search = self._search
277
+ if search is None:
278
+ from ..data.news_search import stock_news as search
279
+
280
+ name = (ctx.info or {}).get("name") or None
281
+ ctx.news = search(ctx.symbol, ctx.market, limit=6, name=name)
282
+ if not ctx.news:
283
+ return AgentOpinion(
284
+ agent_name=self.agent_name,
285
+ summary="No recent news found.",
286
+ signal="neutral",
287
+ confidence=None,
288
+ details={"headlines": []},
289
+ )
290
+
291
+ headlines = "\n".join(
292
+ f"- {(n.get('published') or '')[:10]} {n['title']}" + (f" ({n['source']})" if n.get("source") else "")
293
+ for n in ctx.news
294
+ )
295
+ # The tone of the news, not a view on the stock.
296
+ system_prompt = guard_prompt(
297
+ "You summarise recent headlines about one company. Reply ONLY with JSON: "
298
+ "{\"sentiment\": \"positive\"|\"negative\"|\"neutral\" (the tone of the news), "
299
+ "\"summary\": \"2-3 sentences on what the headlines report\"}. "
300
+ "Ignore headlines that are about a different company.")
301
+ prompt = f"Stock: {ctx.symbol} ({ctx.market})\nRecent headlines:\n{headlines}"
302
+ fallback = json.dumps({
303
+ "sentiment": None, # without a model nobody judged the tone - don't guess
304
+ "summary": f"{len(ctx.news)} recent headlines; latest: {ctx.news[0]['title']}",
305
+ })
306
+ raw = _narrate(backend, system_prompt, prompt, fallback, scope=_scope(ctx, self.agent_name))
307
+ match = _JSON_RE.search(raw)
308
+ try:
309
+ parsed = json.loads(match.group(0)) if match else {}
310
+ except json.JSONDecodeError:
311
+ parsed = {}
312
+ sentiment = str(parsed.get("sentiment") or "").lower() or None
313
+ sentiment = {"positive": "bullish", "negative": "bearish"}.get(sentiment, sentiment)
314
+ if sentiment is not None and sentiment not in ("bullish", "bearish", "neutral"):
315
+ sentiment = "neutral"
316
+ return AgentOpinion(
317
+ agent_name=self.agent_name,
318
+ summary=str(parsed.get("summary") or raw).strip(),
319
+ signal=sentiment,
320
+ confidence=None,
321
+ details={"headlines": [n["title"] for n in ctx.news]},
322
+ )
323
+
324
+
325
+ class FundamentalsAgent:
326
+ """Full / Specialist depth: reads the company's filings, its valuation
327
+ against the sector and the analyst consensus (the same services as the
328
+ Analyze page's cards) so the decision stage sees the business, not only
329
+ the chart. Non-critical: any source that fails or times out is skipped."""
330
+
331
+ agent_name = "fundamentals"
332
+
333
+ def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
334
+ from ..services.analysis_run import yahoo_symbol
335
+ from ..services.estimates import build as build_estimates
336
+ from ..services.financials import build_financials
337
+ from ..services.valuation import meter
338
+ from ..utils.deadline import call_with_deadline
339
+
340
+ if ctx.market == "crypto":
341
+ return AgentOpinion(agent_name=self.agent_name, summary="Company fundamentals don't apply to crypto.",
342
+ signal=None, confidence=None, details={})
343
+
344
+ def safe(fn, seconds, *args):
345
+ try:
346
+ return call_with_deadline(fn, seconds, *args)
347
+ except Exception as exc: # noqa: BLE001 - one missing source is fine
348
+ logger.info("Fundamentals source %s for %s skipped: %s", getattr(fn, "__name__", fn), ctx.symbol, exc)
349
+ return None
350
+
351
+ from concurrent.futures import ThreadPoolExecutor
352
+
353
+ with ThreadPoolExecutor(max_workers=3) as pool: # three independent sources, fetched together
354
+ fin_f = pool.submit(safe, build_financials, 60, ctx.symbol, ctx.market, "annual")
355
+ val_f = pool.submit(safe, meter, 60, ctx.symbol, ctx.market)
356
+ est_f = pool.submit(safe, build_estimates, 45, yahoo_symbol(ctx.symbol, ctx.market))
357
+ fin, val, est = fin_f.result() or {}, val_f.result() or {}, est_f.result() or {}
358
+
359
+ facts: dict = {}
360
+ if fin.get("overall"):
361
+ facts["financial_statements"] = {
362
+ "overall": fin["overall"],
363
+ **{k: {"verdict": (fin.get(k) or {}).get("verdict"),
364
+ "findings": [f["text"] for f in ((fin.get(k) or {}).get("findings") or [])[:4]]}
365
+ for k in ("income", "balance", "cashflow") if fin.get(k)},
366
+ }
367
+ if val.get("status") == "ok":
368
+ facts["valuation_vs_sector"] = {
369
+ "sector": val.get("sector"), "peers": val.get("peers"), "zone": val.get("zone"),
370
+ "multiples": [{"label": m["label"], "value": m.get("value"), "sector_median": m.get("sector_median")}
371
+ for m in val.get("multiples") or []],
372
+ "growth_checks_passed": f"{(val.get('garp') or {}).get('passed')}/{(val.get('garp') or {}).get('total')}",
373
+ }
374
+ if est.get("available"):
375
+ facts["analyst_estimates"] = {
376
+ "eps_trend_90d": (est.get("momentum") or {}).get("label"),
377
+ "beats": est.get("beats"),
378
+ "periods": [{"label": p.get("label"), "eps": (p.get("eps") or {}).get("avg"),
379
+ "eps_growth": (p.get("eps") or {}).get("growth")} for p in (est.get("periods") or [])[:4]],
380
+ }
381
+ if not facts:
382
+ raise RuntimeError("no fundamental data available")
383
+
384
+ system_prompt = guard_prompt(
385
+ "You are a fundamental analyst for a stock research tool. Given the company's financial-statement "
386
+ "checks, its valuation multiples against its sector median and the analyst estimate trend, write "
387
+ "3-4 sentences on the quality of the business, how it is priced relative to its sector, and how "
388
+ "expectations are moving. Use the numbers given; don't invent any."
389
+ )
390
+ prompt = f"Symbol: {ctx.symbol} ({ctx.market})\nFundamental data: {json.dumps(facts, default=str)}"
391
+ bits = []
392
+ if "financial_statements" in facts:
393
+ o = facts["financial_statements"]["overall"]
394
+ bits.append(f"filings read {o.get('label')} ({o.get('score')}/100)")
395
+ if "valuation_vs_sector" in facts:
396
+ v = facts["valuation_vs_sector"]
397
+ where = {"below": "below", "above": "above", "in_line": "in line with"}.get(v["zone"], "against")
398
+ bits.append(f"priced {where} its {v['sector']} sector on P/E, P/B and P/S")
399
+ if "analyst_estimates" in facts:
400
+ e = facts["analyst_estimates"]
401
+ bits.append(f"analyst EPS estimates {e['eps_trend_90d'] or 'unchanged'} over 90 days")
402
+ fallback = "Rule-based read: " + "; ".join(bits) + "."
403
+ narrative = _narrate(backend, system_prompt, prompt, fallback, scope=_scope(ctx, self.agent_name))
404
+ return AgentOpinion(agent_name=self.agent_name, summary=narrative, signal=None, confidence=None, details=facts)
405
+
406
+
407
+ _EXPLAIN_FOCUS = {
408
+ "": (
409
+ "what changed recently, the main strengths and weaknesses in the data, the risks to watch "
410
+ "and the questions an investor would want to research next."
411
+ ),
412
+ "trader": (
413
+ "the current trend and momentum, where price sits against its recent support, resistance "
414
+ "and normal range, how volatile it is, upcoming events in the next few weeks and the "
415
+ "questions a short-term trader would check next."
416
+ ),
417
+ "investor": (
418
+ "how the business is doing (growth, margins, debt), how its valuation compares with its "
419
+ "sector and history, the long-term risks and the questions a long-term investor would "
420
+ "research next."
421
+ ),
422
+ }
423
+
424
+
425
+ def _trade_levels(ctx: AgentContext) -> dict:
426
+ """Support / resistance / ATR from the history TechnicalAgent already
427
+ fetched (same maths as the smart report), for the trader prompt."""
428
+ if ctx.history is None:
429
+ return {}
430
+ try:
431
+ from ..services.smart_report import compute_snapshot
432
+
433
+ snap = compute_snapshot(ctx.history)
434
+ except Exception:
435
+ return {}
436
+ names = {
437
+ "trend": "Trend", "buy_point": "Support", "sell_point": "Resistance", "atr": "ATR (14 days)",
438
+ "normal_low": "Normal range low (20-day band)", "normal_high": "Normal range high",
439
+ "week_low": "1-week expected low", "week_high": "1-week expected high",
440
+ "rsi": "RSI (14)", "volume_ratio": "Volume vs 20-day average",
441
+ }
442
+ return {label: (round(snap[k], 2) if isinstance(snap.get(k), float) else snap.get(k))
443
+ for k, label in names.items() if snap.get(k) is not None}
444
+
445
+
446
+ class DecisionAgent:
447
+ """The last stage: a plain explanation of what the earlier stages found
448
+ and the outlook they point to (bullish / neutral / bearish), never a buy
449
+ / hold / sell call, target or stop loss (marketmyna/core/compliance.py)."""
450
+
451
+ agent_name = "decision"
452
+
453
+ def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
454
+ opinions_text = "\n".join(
455
+ f"- {op.agent_name} (signal={op.signal}): {op.summary}" for op in ctx.opinions
456
+ )
457
+ focus = _EXPLAIN_FOCUS.get(getattr(ctx, "area", None) or "", _EXPLAIN_FOCUS[""])
458
+ system_prompt = guard_prompt(
459
+ "You summarise research for a stock analytics tool that is not a registered adviser. "
460
+ f"Write one paragraph that explains what the figures show: {focus} Never recommend buying, holding, selling, "
461
+ "accumulating or exiting, never give a price target or stop loss, and do not use words "
462
+ "like attractive, cheap or expensive as a verdict. Also give the outlook the findings point to: "
463
+ "bullish, neutral or bearish, as a direction, never as an instruction. Respond ONLY with a JSON "
464
+ 'object: {"outlook": "bullish"|"neutral"|"bearish", "rationale": "one paragraph"}.'
465
+ )
466
+ prompt = (
467
+ f"Symbol: {ctx.symbol} ({ctx.market})\n"
468
+ f"Current price: {(ctx.quote or {}).get('price')}\n\n"
469
+ f"Findings from the analysis steps:\n{opinions_text}"
470
+ )
471
+ if getattr(ctx, "area", None) == "trader":
472
+ levels = _trade_levels(ctx)
473
+ if levels:
474
+ prompt += "\n\nPrice levels (daily bars):\n" + "\n".join(f"- {k}: {v}" for k, v in levels.items())
475
+ if ctx.news:
476
+ prompt += "\n\nRecent headlines:\n" + "\n".join(
477
+ f"- {(n.get('published') or '')[:10]} {n['title']}" for n in ctx.news[:6]
478
+ )
479
+ try:
480
+ from ..services.macro import cached_context
481
+
482
+ macro = cached_context()
483
+ except Exception:
484
+ macro = []
485
+ if macro:
486
+ prompt += "\n\nMacro backdrop (weigh lightly, it moves the whole market):\n" + "\n".join(f"- {m}" for m in macro)
487
+ from .sentiment import social_block
488
+
489
+ social = social_block(ctx)
490
+ if social:
491
+ prompt += "\n\n" + social
492
+ narrative = _call_llm(backend, system_prompt, prompt, scope=_scope(ctx, self.agent_name))
493
+ match = _JSON_RE.search(narrative)
494
+ rationale, direction = narrative, None
495
+ if match:
496
+ try:
497
+ parsed = json.loads(match.group(0))
498
+ rationale = parsed.get("rationale") or narrative
499
+ direction = outlook(parsed.get("outlook"))
500
+ except (json.JSONDecodeError, AttributeError, TypeError):
501
+ pass
502
+ return AgentOpinion(agent_name=self.agent_name, summary=rationale, signal=direction, confidence=None, details={})
503
+
504
+
505
+ class SkillAgent:
506
+ """Applies one Skill's strategy instructions to whatever real context
507
+ (technical summary, risk metrics, quote) is already on ctx. Multiple
508
+ SkillAgents can run in one pipeline, each under its own agent_name
509
+ (f"skill:{skill.name}") so their opinions don't collide in
510
+ ctx.opinions - unlike the fixed-name agents above, this one is
511
+ constructed per-skill rather than being a singleton in the chain."""
512
+
513
+ def __init__(self, skill: Skill):
514
+ self.skill = skill
515
+ self.agent_name = f"skill:{skill.name}"
516
+
517
+ def run(self, ctx: AgentContext, backend: GenerationBackend) -> AgentOpinion:
518
+ system_prompt = (
519
+ f"You are applying a specific trading strategy, '{self.skill.display_name}', "
520
+ "to a stock analysis. Follow the strategy instructions below to form an "
521
+ "opinion. If the instructions call for data that isn't provided here "
522
+ "(e.g. news or sector data), say so explicitly rather than inventing it. "
523
+ "Respond with a concise 2-4 sentence opinion.\n\n"
524
+ f"--- Strategy instructions ---\n{self.skill.instructions}"
525
+ )
526
+ system_prompt = guard_prompt(system_prompt)
527
+ prompt_parts = [f"Symbol: {ctx.symbol} ({ctx.market})"]
528
+ if ctx.quote:
529
+ prompt_parts.append(f"Current price: {ctx.quote.get('price')}")
530
+ if ctx.technical_summary:
531
+ prompt_parts.append(f"Technical summary: {json.dumps(ctx.technical_summary)}")
532
+ if ctx.risk_metrics:
533
+ prompt_parts.append(f"Risk metrics: {json.dumps(ctx.risk_metrics)}")
534
+ narrative = _call_llm(backend, system_prompt, "\n".join(prompt_parts),
535
+ scope=_scope(ctx, self.agent_name))
536
+
537
+ return AgentOpinion(
538
+ agent_name=self.agent_name,
539
+ summary=narrative,
540
+ signal=None,
541
+ confidence=None,
542
+ details={"skill": self.skill.name, "category": self.skill.category},
543
+ )
@@ -0,0 +1,69 @@
1
+ """Shared mutable state passed through the agent pipeline.
2
+
3
+ One AgentContext is created per orchestrator run and threaded through
4
+ every agent in the chain: each agent reads what earlier agents put on
5
+ it (e.g. RiskAgent reads the history TechnicalAgent already fetched
6
+ rather than fetching it again) and appends its own AgentOpinion.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any, Optional
14
+
15
+ import pandas as pd
16
+
17
+
18
+ @dataclass
19
+ class AgentOpinion:
20
+ """One agent's contribution to the pipeline: a narrative summary
21
+ (usually LLM-generated) plus whatever structured signal it computed."""
22
+
23
+ agent_name: str
24
+ summary: str
25
+ signal: Optional[str] = None
26
+ confidence: Optional[float] = None
27
+ details: dict[str, Any] = field(default_factory=dict)
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ "agent_name": self.agent_name,
32
+ "summary": self.summary,
33
+ "signal": self.signal,
34
+ "confidence": self.confidence,
35
+ "details": self.details,
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class AgentContext:
41
+ symbol: str
42
+ market: str
43
+ mode: str = "standard"
44
+ # "trader" / "investor" (the login area) shapes the final decision;
45
+ # None = the original, area-neutral prompt (bot, scheduler, reports).
46
+ area: Optional[str] = None
47
+
48
+ quote: Optional[dict] = None
49
+ history: Optional[pd.DataFrame] = None
50
+ info: Optional[dict] = None
51
+ technical_summary: Optional[dict] = None
52
+ fundamental_summary: Optional[dict] = None
53
+ risk_metrics: Optional[dict] = None
54
+ # Recent headlines (marketmyna/data/news_search.py dicts), set by NewsAgent.
55
+ news: Optional[list] = None
56
+ # What people say online (marketmyna/agent/sentiment.py): {"summary", "excerpts"}; None when not run or no posts.
57
+ social: Optional[dict] = None
58
+
59
+ opinions: list[AgentOpinion] = field(default_factory=list)
60
+ generated_at: datetime = field(default_factory=datetime.now)
61
+
62
+ def add_opinion(self, opinion: AgentOpinion) -> None:
63
+ self.opinions.append(opinion)
64
+
65
+ def get_opinion(self, agent_name: str) -> Optional[AgentOpinion]:
66
+ for opinion in self.opinions:
67
+ if opinion.agent_name == agent_name:
68
+ return opinion
69
+ return None