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
@@ -0,0 +1,584 @@
1
+ """Scheduled-report content: which sections a user receives, in which
2
+ language, on which channel - and the builder that renders it.
3
+
4
+ Plain text with emoji (no Markdown/HTML) so Telegram, email, ntfy,
5
+ Slack, WeChat... all render the same message.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from datetime import date, datetime
13
+ from typing import Iterable, Optional
14
+ from zoneinfo import ZoneInfo
15
+
16
+ from ..core.compliance import guard_prompt, outlook
17
+ from .report_i18n import has_builtin, language_name, normalize_language, t, tv
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # (id, English label, description, on by default in its area)
22
+ SECTIONS: list[tuple[str, str, str, bool]] = [
23
+ ("market", "Market overview", "Index levels and day change for each market in the report", True),
24
+ ("calls", "Movers & AI outlook", "Biggest gainers and losers, stocks near a 52-week high or low, and each stock's bullish / neutral / bearish AI outlook", True),
25
+ ("portfolio", "Your portfolio", "Portfolio value, today's change, total gain and the holdings that moved it most", True),
26
+ ("alerts", "Your alerts", "Price alerts that fired since the last report, and active ones close to their trigger", True),
27
+ ("hot_trade", "Hot for trading", "Today's biggest moves on heavy trading, unusual activity and breakouts in your markets", True),
28
+ ("hot_invest", "Trending for investors", "Quality stocks on a dip, earnings momentum and what mutual funds added", False),
29
+ ("funds", "Mutual funds", "NAV, day change and 1-month / 1-year returns for the funds you track", True),
30
+ ("price", "Price & range", "Price, day change and 52-week range", True),
31
+ ("levels", "Trading range", "Normal range (20-day band), expected 1-week range and average daily move (ATR)", True),
32
+ ("ai", "AI summary", "What changed, strengths, weaknesses and risks to watch - no buy/sell call", True),
33
+ ("technical", "Technical readings", "Indicator values such as RSI and MACD, without a verdict", True),
34
+ ("indicators", "Technical indicators", "RSI, MACD, moving averages, Bollinger bands (values)", False),
35
+ ("fundamentals", "Fundamentals", "Score, P/E, ROE, financial health, growth, dividend", False),
36
+ ("risk", "Risk", "Risk level, volatility and maximum drawdown", True),
37
+ ("analysts", "Analyst ratings", "Broker consensus of third-party analysts, as bullish / neutral / bearish", False),
38
+ ("events", "Upcoming events", "Next earnings date and ex-dividend date", False),
39
+ ("news", "News headlines", "Latest headlines for each stock", False),
40
+ ("valuation", "Valuation vs sector", "P/E, price/book and price/sales against the median of the stock's sector", False),
41
+ ]
42
+
43
+ # Which side of the app a section is for (absent = both). The Scheduler shows
44
+ # each area only its own sections and keeps the other area's choices as saved.
45
+ SECTION_AREAS: dict[str, str] = {
46
+ "portfolio": "investor", "hot_invest": "investor", "funds": "investor",
47
+ "fundamentals": "investor", "analysts": "investor", "valuation": "investor",
48
+ "alerts": "trader", "hot_trade": "trader", "levels": "trader", "indicators": "trader",
49
+ }
50
+
51
+ SECTION_IDS = [s[0] for s in SECTIONS]
52
+ DEFAULT_SECTIONS = [s[0] for s in SECTIONS if s[3]]
53
+ # Report-wide sections built once per run, not per stock.
54
+ REPORT_WIDE = {"market", "calls", "portfolio", "alerts", "hot_trade", "hot_invest", "funds"}
55
+
56
+ MARKET_NAMES = {
57
+ "us": "US", "india": "India", "china_a": "China A", "hong_kong": "Hong Kong", "crypto": "Crypto", "global": "Global",
58
+ }
59
+ CURRENCY_SYMBOLS = {"india": "โ‚น", "us": "$", "china_a": "ยฅ", "hong_kong": "HK$", "crypto": "$", "global": ""}
60
+ OUTLOOK_ICONS = {"bullish": "โ–ฒ", "neutral": "โ—†", "bearish": "โ–ผ"}
61
+
62
+
63
+ def clean_sections(values: Optional[Iterable[str]]) -> list[str]:
64
+ """Known section ids only, in catalogue order."""
65
+ wanted = {v.strip().lower() for v in (values or []) if v and v.strip()}
66
+ return [s for s in SECTION_IDS if s in wanted]
67
+
68
+
69
+ @dataclass
70
+ class ReportPrefs:
71
+ language: str = "en"
72
+ sections: list[str] = field(default_factory=lambda: list(DEFAULT_SECTIONS))
73
+ # symbol -> its own section list, overriding `sections` for that stock
74
+ stock_sections: dict[str, list[str]] = field(default_factory=dict)
75
+ # channel name -> language, overriding `language` for that channel
76
+ channel_languages: dict[str, str] = field(default_factory=dict)
77
+ mode: str = "standard"
78
+ timezone: str = "Asia/Kolkata"
79
+ # AMFI scheme codes for the "funds" section
80
+ funds: list[str] = field(default_factory=list)
81
+ # Portfolio names for the "portfolio" section (empty = all)
82
+ portfolios: list[str] = field(default_factory=list)
83
+ # Smart messaging: short top-N message + link to the full report
84
+ smart: bool = False
85
+ smart_top_n: int = 5
86
+ smart_ai_top: int = 10 # AI analyses only the N most interesting stocks (0 = none)
87
+ smart_email_full: bool = True
88
+ # The report file sent with the short message: "pdf", "xlsx" or "none",
89
+ # built from the ticked sections, to these channels (marketmyna/services/smart_file.py).
90
+ smart_file: str = "pdf"
91
+ smart_file_channels: list[str] = field(default_factory=lambda: ["email", "telegram"])
92
+ report_base_url: Optional[str] = None
93
+
94
+ def sections_for(self, symbol: str) -> list[str]:
95
+ own = self.stock_sections.get(symbol.upper())
96
+ return own if own is not None else self.sections
97
+
98
+ def all_sections(self) -> set[str]:
99
+ out = set(self.sections)
100
+ for secs in self.stock_sections.values():
101
+ out.update(secs)
102
+ return out
103
+
104
+ def languages_needed(self, channels: Iterable[str]) -> dict[str, list[str]]:
105
+ """language -> channels that should receive it."""
106
+ by_lang: dict[str, list[str]] = {}
107
+ for ch in channels:
108
+ lang = normalize_language(self.channel_languages.get(ch) or self.language)
109
+ by_lang.setdefault(lang, []).append(ch)
110
+ return by_lang
111
+
112
+
113
+ # ------------------------------------------------------------ formatting
114
+
115
+
116
+ def _num(value) -> Optional[float]:
117
+ try:
118
+ v = float(value)
119
+ except (TypeError, ValueError):
120
+ return None
121
+ return v if v == v else None # NaN -> None
122
+
123
+
124
+ def _fmt(value, digits: int = 2) -> Optional[str]:
125
+ v = _num(value)
126
+ if v is None:
127
+ return None
128
+ return f"{v:,.{digits}f}" if abs(v) >= 1 else f"{v:.4f}"
129
+
130
+
131
+ def _pct(value, signed: bool = True) -> Optional[str]:
132
+ v = _num(value)
133
+ if v is None:
134
+ return None
135
+ return f"{v:+.2f}%" if signed else f"{v:.1f}%"
136
+
137
+
138
+ def _one_line(text: Optional[str], limit: int = 240) -> str:
139
+ text = " ".join((text or "").split())
140
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "โ€ฆ"
141
+
142
+
143
+ def _future(d) -> Optional[str]:
144
+ """Format a date only if it's today or later."""
145
+ if d is None:
146
+ return None
147
+ try:
148
+ day = d if isinstance(d, date) else datetime.fromisoformat(str(d)).date()
149
+ except ValueError:
150
+ return None
151
+ return day.strftime("%d %b %Y") if day >= date.today() else None
152
+
153
+
154
+ def _market_lines(overview: list[dict], lang: str) -> list[str]:
155
+ if not overview:
156
+ return []
157
+ lines = [f"๐ŸŒ {t(lang, 'market_overview')}"]
158
+ for idx in overview:
159
+ change = _pct(idx.get("change_percent"))
160
+ arrow = "โ–ฒ" if (_num(idx.get("change_percent")) or 0) >= 0 else "โ–ผ"
161
+ lines.append(f" {arrow} {idx['name']} {_fmt(idx.get('price')) or 'โ€”'}" + (f" ({change})" if change else ""))
162
+ return lines
163
+
164
+
165
+ def _stock_block(r: dict, sections: list[str], lang: str, multi_market: bool) -> list[str]:
166
+ dash = r.get("dashboard") or {}
167
+ quote = r.get("quote") or {}
168
+ extras = r.get("extras") or {}
169
+ cur = CURRENCY_SYMBOLS.get(r["market"], "")
170
+ title = f"{'๐Ÿ“Œ' if r.get('ok') else 'โš ๏ธ'} {r['symbol']}"
171
+ if r.get("name") and r["name"].upper() != r["symbol"].upper():
172
+ title += f" ยท {r['name']}"
173
+ if dash.get("outlook"):
174
+ word = tv(lang, dash["outlook"])
175
+ title += f" โ€” {OUTLOOK_ICONS[dash['outlook']]} {word.capitalize() if lang == 'en' else word}"
176
+ if multi_market:
177
+ title += f" [{MARKET_NAMES.get(r['market'], r['market'])}]"
178
+ lines = [title]
179
+
180
+ if "price" in sections:
181
+ bits = []
182
+ price = _fmt(quote.get("price"))
183
+ if price:
184
+ change = _pct(quote.get("change_percent"))
185
+ bits.append(f"{t(lang, 'price')} {cur}{price}" + (f" ({change})" if change else ""))
186
+ if bits:
187
+ lines.append(" ๐Ÿ’ฐ " + " ยท ".join(bits))
188
+ lo, hi = _fmt(extras.get("low_52w")), _fmt(extras.get("high_52w"))
189
+ if lo and hi:
190
+ lines.append(f" ๐Ÿ“ {t(lang, 'range_52w')}: {cur}{lo} โ€“ {cur}{hi}")
191
+
192
+ lv = extras.get("levels") or {}
193
+ if "levels" in sections and lv:
194
+ lines += _level_lines(lv, cur, lang)
195
+
196
+ if "ai" in sections:
197
+ if dash.get("analysis_summary"):
198
+ lines.append(f" ๐Ÿค– {t(lang, 'ai_summary')}: {_one_line(dash['analysis_summary'])}")
199
+ elif not r.get("ok"):
200
+ lines.append(f" ๐Ÿค– {t(lang, 'ai_unavailable')}: {_one_line(r.get('error'), 160)}")
201
+
202
+ technical = next((o for o in dash.get("opinions", []) if o.get("agent_name") == "technical"), None)
203
+ if "technical" in sections and technical:
204
+ readings = _readings(technical)
205
+ if readings:
206
+ lines.append(f" ๐Ÿ“ˆ {t(lang, 'readings')}: {readings}")
207
+
208
+ if "indicators" in sections and technical:
209
+ indicators = (technical.get("details") or {}).get("indicators") or {}
210
+ bits = []
211
+ for name, info in indicators.items():
212
+ value = _fmt(info.get("value"))
213
+ if value:
214
+ bits.append(f"{name.upper()} {value}")
215
+ if bits:
216
+ lines.append(f" ๐Ÿ“ {t(lang, 'indicators')}: " + " ยท ".join(bits))
217
+
218
+ fund = extras.get("fundamentals")
219
+ if "fundamentals" in sections and fund:
220
+ bits = [f"{t(lang, 'score')} {fund.get('overall_score', 'โ€”')}/100"]
221
+ val = (fund.get("valuation") or {})
222
+ if _fmt(val.get("value")):
223
+ bits.append(f"{t(lang, 'pe')} {_fmt(val['value'], 1)}")
224
+ roe = ((fund.get("profitability") or {}).get("roe") or {}).get("value")
225
+ if _num(roe):
226
+ bits.append(f"{t(lang, 'roe')} {_pct(roe, signed=False)}")
227
+ health = (fund.get("financial_health") or {}).get("rating")
228
+ if health:
229
+ bits.append(f"{t(lang, 'health')} {tv(lang, health)}")
230
+ growth = ((fund.get("growth") or {}).get("revenue_growth") or {}).get("value")
231
+ if _num(growth):
232
+ bits.append(f"{t(lang, 'revenue_growth')} {_pct(growth)}")
233
+ dy = (fund.get("dividend") or {}).get("yield")
234
+ if _num(dy):
235
+ bits.append(f"{t(lang, 'dividend_yield')} {_pct(dy, signed=False)}")
236
+ lines.append(f" ๐Ÿฆ {t(lang, 'fundamentals')}: " + " ยท ".join(bits))
237
+
238
+ if "risk" in sections and dash.get("risk_level"):
239
+ bits = [tv(lang, dash["risk_level"])]
240
+ risk = next((o for o in dash.get("opinions", []) if o.get("agent_name") == "risk"), None)
241
+ details = (risk or {}).get("details") or {}
242
+ if _num(details.get("volatility")) is not None:
243
+ bits.append(f"{t(lang, 'volatility')} {_num(details['volatility']) * 100:.0f}%")
244
+ if _num(details.get("max_drawdown")) is not None:
245
+ bits.append(f"{t(lang, 'drawdown')} {_num(details['max_drawdown']) * 100:.0f}%")
246
+ lines.append(f" โš–๏ธ {t(lang, 'risk')}: " + " ยท ".join(bits))
247
+
248
+ analysts = extras.get("analysts")
249
+ if "analysts" in sections and analysts and analysts.get("recommendation"):
250
+ # A direction, without the brokers' price target.
251
+ word = outlook(analysts["recommendation"]) or analysts["recommendation"]
252
+ text = f" ๐ŸŽฏ {t(lang, 'consensus_src')}: {tv(lang, word)}"
253
+ if analysts.get("count"):
254
+ text += f" ({t(lang, 'n_analysts', n=analysts['count'])})"
255
+ lines.append(text)
256
+
257
+ events = extras.get("events") or {}
258
+ if "events" in sections:
259
+ bits = []
260
+ if _future(events.get("earnings_date")):
261
+ bits.append(f"{t(lang, 'earnings')} {_future(events['earnings_date'])}")
262
+ if _future(events.get("ex_dividend_date")):
263
+ bits.append(f"{t(lang, 'ex_dividend')} {_future(events['ex_dividend_date'])}")
264
+ if bits:
265
+ lines.append(f" ๐Ÿ“… {t(lang, 'upcoming')}: " + " ยท ".join(bits))
266
+
267
+ news = extras.get("news") or []
268
+ news_view = next((o for o in dash.get("opinions", []) if o.get("agent_name") == "news"), None)
269
+ if "news" in sections and news:
270
+ header = f" ๐Ÿ“ฐ {t(lang, 'news')}"
271
+ summary = (news_view or {}).get("summary")
272
+ lines.append(f"{header}: {_one_line(summary, 220)}" if summary else f"{header}:")
273
+ for item in news:
274
+ source = f" โ€” {item['source']}" if item.get("source") else ""
275
+ lines.append(f" โ€ข {_one_line(item.get('title'), 140)}{source}")
276
+
277
+ val = extras.get("valuation") or {}
278
+ if "valuation" in sections and val.get("position"):
279
+ bits = [f"{m['label']} {_fmt(m['value'], 1)} vs {_fmt(m['sector_median'], 1)}"
280
+ for m in val.get("multiples", []) if m.get("ratio")]
281
+ zone = val.get("zone")
282
+ word = t(lang, f"zone_{zone}") if zone else ""
283
+ lines.append(f" โš–๏ธ {t(lang, 'valuation_vs_sector')}: {' ยท '.join(bits)} โ†’ {val['position']:.2f}ร— ({word})")
284
+ return lines
285
+
286
+
287
+ def _level_lines(lv: dict, cur: str, lang: str) -> list[str]:
288
+ """Trading range from daily bars (marketmyna/services/smart_report.compute_snapshot):
289
+ ranges only, no support / resistance / stop to act on."""
290
+ price = _num(lv.get("price"))
291
+ atr = _num(lv.get("atr"))
292
+ bits = []
293
+ lo, hi = _fmt(lv.get("normal_low")), _fmt(lv.get("normal_high"))
294
+ if lo and hi:
295
+ bits.append(f"{t(lang, 'normal_range')} {cur}{lo} โ€“ {cur}{hi}")
296
+ lines = [" ๐ŸŽš๏ธ " + " ยท ".join(bits)] if bits else []
297
+ more = []
298
+ wl, wh = _fmt(lv.get("week_low")), _fmt(lv.get("week_high"))
299
+ if wl and wh:
300
+ more.append(f"{t(lang, 'week_range')} {cur}{wl} โ€“ {cur}{wh}")
301
+ if atr and price:
302
+ more.append(f"ATR {cur}{_fmt(atr)} ({atr / price * 100:.1f}%)")
303
+ if lv.get("trend"):
304
+ more.append(tv(lang, lv["trend"]))
305
+ if more:
306
+ lines.append(" " + " ยท ".join(more))
307
+ return lines
308
+
309
+
310
+ def _portfolio_lines(portfolios: list[dict], lang: str) -> list[str]:
311
+ lines = []
312
+ for p in portfolios:
313
+ cur = p.get("currency_symbol", "")
314
+ head = f"๐Ÿ’ผ {p['name']}: {cur}{_fmt(p['value'], 0)}"
315
+ if _num(p.get("day_change")) is not None:
316
+ head += f" ยท {t(lang, 'today')} {'+' if p['day_change'] >= 0 else '-'}{cur}{_fmt(abs(p['day_change']), 0)}"
317
+ if _pct(p.get("day_change_pct")):
318
+ head += f" ({_pct(p['day_change_pct'])})"
319
+ lines.append(head)
320
+ if _num(p.get("gain")) is not None:
321
+ lines.append(f" {t(lang, 'total_gain')} {'+' if p['gain'] >= 0 else '-'}{cur}{_fmt(abs(p['gain']), 0)}"
322
+ + (f" ({_pct(p.get('gain_pct'))})" if _pct(p.get("gain_pct")) else ""))
323
+ for m in p.get("movers", []):
324
+ lines.append(f" {'โ–ฒ' if m['change'] >= 0 else 'โ–ผ'} {m['symbol']} {_pct(m['change_pct'])} "
325
+ f"({'+' if m['change'] >= 0 else '-'}{cur}{_fmt(abs(m['change']), 0)})")
326
+ return lines
327
+
328
+
329
+ def _alert_lines(alerts: dict, lang: str) -> list[str]:
330
+ lines = [f"๐Ÿ”” {t(lang, 'your_alerts')}"]
331
+ fired = alerts.get("fired") or []
332
+ near = alerts.get("near") or []
333
+ if fired:
334
+ lines.append(f" {t(lang, 'alerts_fired', n=len(fired))}:")
335
+ for e in fired[:8]:
336
+ lines.append(f" โ€ข {e['symbol']} โ€” {_one_line(e.get('message'), 120)}")
337
+ if near:
338
+ lines.append(f" {t(lang, 'alerts_near')}:")
339
+ for a in near[:8]:
340
+ lines.append(f" โ€ข {a['symbol']} {a['describe']} ยท {abs(a['distance_pct']):.1f}% away")
341
+ if not fired and not near:
342
+ lines.append(f" {t(lang, 'alerts_quiet', n=alerts.get('active', 0))}")
343
+ return lines
344
+
345
+
346
+ def _hot_lines(hot: dict, lang: str) -> list[str]:
347
+ key = "hot_trade" if hot.get("area") == "trader" else "hot_invest"
348
+ lines = [f"๐Ÿ”ฅ {t(lang, key)}"]
349
+ for market, boards in hot.get("markets", {}).items():
350
+ for b in boards:
351
+ if not b["rows"]:
352
+ continue
353
+ head = b["title"] + (f" [{MARKET_NAMES.get(market, market)}]" if len(hot["markets"]) > 1 else "")
354
+ lines.append(f" {head}")
355
+ for row in b["rows"]:
356
+ lines.append(f" โ€ข {row['symbol']} {_pct(row.get('change_percent')) or ''} โ€” {_one_line(row.get('why'), 110)}")
357
+ return lines if len(lines) > 1 else []
358
+
359
+
360
+ def _readings(technical: dict) -> str:
361
+ """Indicator values only (no buy/sell verdict)."""
362
+ indicators = (technical.get("details") or {}).get("indicators") or {}
363
+ return " ยท ".join(f"{name.upper()} {_fmt(info.get('value'))}" for name, info in indicators.items() if _fmt(info.get("value")))
364
+
365
+
366
+ def _outlook_lines(results: list[dict], lang: str) -> list[str]:
367
+ """The AI outlook of each stock, grouped (bullish / neutral / bearish)."""
368
+ groups: dict[str, list[str]] = {"bullish": [], "neutral": [], "bearish": []}
369
+ for r in results:
370
+ direction = (r.get("dashboard") or {}).get("outlook")
371
+ if direction in groups:
372
+ groups[direction].append(r["symbol"])
373
+ if not any(groups.values()):
374
+ return []
375
+ lines = ["", f"๐Ÿงญ {t(lang, 'outlook')}"]
376
+ for key, symbols in groups.items():
377
+ word = tv(lang, key)
378
+ lines.append(f" {OUTLOOK_ICONS[key]} {word.capitalize() if lang == 'en' else word}: {', '.join(symbols) or 'โ€”'}")
379
+ return lines
380
+
381
+
382
+ def _mover_lines(results: list[dict], lang: str) -> list[str]:
383
+ """Facts about the user's own list: biggest moves and 52-week extremes."""
384
+ moves = [(r["symbol"], _num((r.get("quote") or {}).get("change_percent"))) for r in results]
385
+ moves = [m for m in moves if m[1] is not None]
386
+ up = sorted((m for m in moves if m[1] > 0), key=lambda m: -m[1])[:3]
387
+ down = sorted((m for m in moves if m[1] < 0), key=lambda m: m[1])[:3]
388
+ near_hi, near_lo = [], []
389
+ for r in results:
390
+ price = _num((r.get("quote") or {}).get("price"))
391
+ hi, lo = _num((r.get("extras") or {}).get("high_52w")), _num((r.get("extras") or {}).get("low_52w"))
392
+ if price and hi and price >= hi * 0.97:
393
+ near_hi.append(r["symbol"])
394
+ if price and lo and price <= lo * 1.03:
395
+ near_lo.append(r["symbol"])
396
+ fmt = lambda ms: ", ".join(f"{s} {_pct(c)}" for s, c in ms) or "โ€”"
397
+ lines = [f"๐Ÿ“Š {t(lang, 'movers')}", f" โ–ฒ {t(lang, 'up_most')}: {fmt(up)}", f" โ–ผ {t(lang, 'down_most')}: {fmt(down)}"]
398
+ if near_hi:
399
+ lines.append(f" โค’ {t(lang, 'near_high')}: {', '.join(near_hi)}")
400
+ if near_lo:
401
+ lines.append(f" โค“ {t(lang, 'near_low')}: {', '.join(near_lo)}")
402
+ return lines
403
+
404
+
405
+ def _fund_lines(funds: list[dict], lang: str) -> list[str]:
406
+ lines = [f"๐Ÿ’ผ {t(lang, 'funds')}"]
407
+ for f in funds:
408
+ change = _pct(f.get("change_percent"))
409
+ bits = [f"{t(lang, 'nav')} โ‚น{_fmt(f.get('nav'), 4 if (_num(f.get('nav')) or 0) < 100 else 2)}"
410
+ + (f" ({change})" if change else "")]
411
+ if _pct(f.get("return_1m")):
412
+ bits.append(f"1M {_pct(f['return_1m'])}")
413
+ if _pct(f.get("return_1y")):
414
+ bits.append(f"1Y {_pct(f['return_1y'])}")
415
+ lines.append(f" โ€ข {_one_line(f.get('name'), 90)}")
416
+ lines.append(f" {' ยท '.join(bits)}")
417
+ dip = f.get("lumpsum") or {}
418
+ if dip.get("status") == "ok" and dip.get("level") in ("watch", "strong"):
419
+ below = f"{-dip['below_high'] * 100:.1f}% below high"
420
+ lines.append(f" ๐Ÿ“‰ {t(lang, 'dip_check')} {dip['met']}/{dip['total']} ยท {below}")
421
+ return lines
422
+
423
+
424
+ def build_report(
425
+ results: list[dict],
426
+ prefs: ReportPrefs,
427
+ lang: str = "en",
428
+ overview: Optional[list[dict]] = None,
429
+ now: Optional[datetime] = None,
430
+ funds: Optional[list[dict]] = None,
431
+ market_take: Optional[str] = None,
432
+ portfolios: Optional[list[dict]] = None,
433
+ alerts: Optional[dict] = None,
434
+ hot: Optional[list[dict]] = None,
435
+ ) -> tuple[str, str]:
436
+ """(title, body) for one language. Labels come from the built-in
437
+ translations; free text stays as generated (see translate_report)."""
438
+ lang = normalize_language(lang)
439
+ label_lang = lang if has_builtin(lang) else "en"
440
+ now = (now or datetime.now(ZoneInfo(prefs.timezone))).astimezone(ZoneInfo(prefs.timezone))
441
+ markets = list(dict.fromkeys(r["market"] for r in results))
442
+ multi = len(markets) > 1
443
+
444
+ mode_label = t(label_lang, f"mode_{prefs.mode}") if prefs.mode in ("quick", "standard", "full", "specialist") else prefs.mode
445
+ lines = [
446
+ f"๐Ÿ“Š {' + '.join(MARKET_NAMES.get(m, m) for m in markets)} ยท {t(label_lang, 'analysis', mode=mode_label)}",
447
+ f"๐Ÿ•˜ {now:%d %b %Y, %H:%M} {now.tzname()}",
448
+ ]
449
+
450
+ if "market" in prefs.sections and overview:
451
+ lines += [""] + _market_lines(overview, label_lang)
452
+ if market_take:
453
+ lines.append(f" ๐Ÿค– {_one_line(market_take, 400)}")
454
+
455
+ if "calls" in prefs.sections:
456
+ lines += [""] + _mover_lines(results, label_lang) + _outlook_lines(results, label_lang)
457
+
458
+ if "portfolio" in prefs.sections and portfolios:
459
+ lines += [""] + _portfolio_lines(portfolios, label_lang)
460
+
461
+ if "alerts" in prefs.sections and alerts:
462
+ lines += [""] + _alert_lines(alerts, label_lang)
463
+
464
+ for block in hot or []:
465
+ if block.get("section") in prefs.sections:
466
+ hot_lines = _hot_lines(block, label_lang)
467
+ if hot_lines:
468
+ lines += [""] + hot_lines
469
+
470
+ if "funds" in prefs.sections and funds:
471
+ lines += [""] + _fund_lines(funds, label_lang)
472
+
473
+ for r in results:
474
+ # Always at least the stock's name line, as Settings promises when no section is ticked.
475
+ lines += [""] + _stock_block(r, prefs.sections_for(r["symbol"]), label_lang, multi)
476
+
477
+ lines += ["", t(label_lang, "disclaimer")]
478
+ return t(label_lang, "title"), "\n".join(lines)
479
+
480
+
481
+ # ---------------------------------------------------------- translation
482
+
483
+ _TRANSLATE_SYSTEM = (
484
+ "You translate stock-market reports for a notification message. Translate every piece of "
485
+ "English text into {language}. Keep unchanged: ticker symbols, company names, numbers, "
486
+ "percentages, currency symbols, dates, emoji and the exact line breaks and indentation. "
487
+ "Text already in {language} stays as is. Output only the translated report, nothing else."
488
+ )
489
+
490
+
491
+ def translate_report(text: str, lang: str, config) -> Optional[str]:
492
+ """Translate free text with the configured LLM; None if unavailable.
493
+ Sent in blocks so long reports fit the model's output budget."""
494
+ from ..llm.channels import llm_configured
495
+
496
+ if lang == "en" or not llm_configured(config):
497
+ return None
498
+ from ..llm.backend_factory import create_generation_backend
499
+ from ..llm.protocol import GenerationConfig
500
+
501
+ try:
502
+ backend = create_generation_backend(config, tier="fast") # translation is a level-1 job
503
+ except Exception as exc:
504
+ logger.warning("Translation backend unavailable: %s", exc)
505
+ return None
506
+
507
+ system = _TRANSLATE_SYSTEM.format(language=language_name(lang))
508
+ blocks, current = [], ""
509
+ for para in text.split("\n\n"):
510
+ if current and len(current) + len(para) > 1500:
511
+ blocks.append(current)
512
+ current = para
513
+ else:
514
+ current = f"{current}\n\n{para}" if current else para
515
+ if current:
516
+ blocks.append(current)
517
+
518
+ out = []
519
+ for block in blocks:
520
+ try:
521
+ result = backend.generate(block, GenerationConfig(), system_prompt=system)
522
+ translated = (result.text or "").strip()
523
+ out.append(translated or block)
524
+ except Exception as exc:
525
+ logger.warning("Translation to %s failed: %s", lang, exc)
526
+ return None
527
+ return "\n\n".join(out)
528
+
529
+
530
+ def render_reports(
531
+ results: list[dict], prefs: ReportPrefs, languages: Iterable[str], config=None,
532
+ overview: Optional[list[dict]] = None, now: Optional[datetime] = None,
533
+ funds: Optional[list[dict]] = None, market_take: Optional[str] = None,
534
+ portfolios: Optional[list[dict]] = None,
535
+ alerts: Optional[dict] = None,
536
+ hot: Optional[list[dict]] = None,
537
+ ) -> dict[str, tuple[str, str]]:
538
+ """language -> (title, body), translating free text when an LLM is set."""
539
+ out = {}
540
+ for lang in dict.fromkeys(normalize_language(x) for x in languages):
541
+ title, body = build_report(results, prefs, lang, overview, now, funds=funds, market_take=market_take,
542
+ portfolios=portfolios, alerts=alerts, hot=hot)
543
+ if lang != "en":
544
+ translated = translate_report(body, lang, config) if config is not None else None
545
+ if translated:
546
+ body = translated
547
+ if not has_builtin(lang):
548
+ title = translate_report(title, lang, config) or title
549
+ else:
550
+ body += "\n" + t(lang if has_builtin(lang) else "en", "english_note")
551
+ out[lang] = (title, body)
552
+ return out
553
+
554
+
555
+ def market_review(overview: list[dict], results: list[dict], config) -> Optional[str]:
556
+ """Two-sentence AI read of the day's market backdrop (English; the
557
+ report translation step localizes it). None without an LLM."""
558
+ from ..llm.channels import llm_configured
559
+
560
+ if not overview or config is None or not llm_configured(config):
561
+ return None
562
+ from ..llm.backend_factory import create_generation_backend
563
+ from ..llm.protocol import GenerationConfig
564
+
565
+ indices = "\n".join(
566
+ f"- {i['name']}: {_fmt(i.get('price')) or '?'} ({_pct(i.get('change_percent')) or '?'})" for i in overview
567
+ )
568
+ outlooks = ", ".join(
569
+ f"{r['symbol']}={(r.get('dashboard') or {}).get('outlook') or 'n/a'}" for r in results
570
+ )
571
+ try:
572
+ result = create_generation_backend(config, tier="fast").generate(
573
+ f"Index moves today:\n{indices}\n\nAI outlook on the watchlist: {outlooks}",
574
+ GenerationConfig(max_tokens=200),
575
+ system_prompt=guard_prompt(
576
+ "You are a markets editor. In at most two short sentences, describe today's market "
577
+ "backdrop for a retail investor and what it means for the watchlist, using only the "
578
+ "numbers given. No bullet points, no disclaimers, no predictions stated as fact."
579
+ ),
580
+ )
581
+ return " ".join((result.text or "").split()) or None
582
+ except Exception as exc:
583
+ logger.warning("Market review unavailable: %s", exc)
584
+ return None