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,595 @@
1
+ """The report file Smart messaging sends with its short message.
2
+
3
+ Not a copy of every section ticked for the message: the file always holds
4
+ the same core set, the figures that matter for each stock, rendered as a
5
+ PDF (to read) or an Excel workbook (to sort and filter):
6
+
7
+ summary tiles (stocks, bullish / neutral / bearish counts, best / worst today)
8
+ market index levels and the AI's read of the day
9
+ readings what the indicators show, bullish / neutral / bearish; never a
10
+ buy / sell call (marketmyna/core/compliance.py)
11
+ portfolio value, today's change and total gain, when the schedule has one
12
+ stocks price, day, reading, valuation vs sector and the AI summary
13
+ guide how to read the report, term by term
14
+ cards one per stock: ranges, trend, RSI, risk, next results, the AI
15
+ summary and valuation vs sector
16
+
17
+ News, alerts and mutual funds stay in the message and the
18
+ report link. Nothing here calls the AI except the one-paragraph market read.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ import logging
25
+ from concurrent.futures import ThreadPoolExecutor
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from typing import Optional
29
+
30
+ from ..core.numbers import finite as _num
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ WORKERS = 6
35
+ # Per-stock data the file needs from collect_extras (marketmyna/services/analysis_run.py).
36
+ FILE_EXTRAS = {"valuation", "events"}
37
+ FILE_TYPES = {"pdf": "pdf", "xlsx": "xlsx"}
38
+ # Marks AI-reviewed stocks (plain text: the PDF font has no emoji).
39
+ AI_TAG = " · AI"
40
+ DISCLAIMER = "MarketMyna · Computed from market data; AI notes may be wrong. Not investment advice."
41
+
42
+
43
+ @dataclass
44
+ class Table:
45
+ title: str
46
+ columns: list[str]
47
+ rows: list[list] = field(default_factory=list)
48
+ note: str = ""
49
+ # The columns the PDF shows when the page is too narrow for all (Excel keeps every one).
50
+ pdf_columns: Optional[list[str]] = None
51
+
52
+
53
+ @dataclass
54
+ class Text:
55
+ title: str
56
+ lines: list[str] = field(default_factory=list)
57
+
58
+
59
+ @dataclass
60
+ class Tile:
61
+ """A summary figure at the top of the report."""
62
+ label: str
63
+ value: str
64
+ sub: str = ""
65
+ tone: Optional[str] = None # up | down | warn | accent
66
+
67
+
68
+ @dataclass
69
+ class StockCard:
70
+ """One stock's details, following the ticked sections."""
71
+ symbol: str
72
+ name: str
73
+ call: str
74
+ price: str
75
+ change: str
76
+ facts: list[tuple[str, str]] = field(default_factory=list)
77
+ # Groups of (label, text, tone) shown side by side: the AI's view, bull vs bear, the risk team.
78
+ notes: list[list[tuple[str, str, Optional[str]]]] = field(default_factory=list)
79
+ news: list[str] = field(default_factory=list)
80
+
81
+
82
+ @dataclass
83
+ class Document:
84
+ title: str
85
+ subtitle: str
86
+ blocks: list = field(default_factory=list)
87
+ highlights: list[Tile] = field(default_factory=list)
88
+ cards: list[StockCard] = field(default_factory=list)
89
+ # Shown in a box under the summary: what this report is and is not (general users).
90
+ notice: str = ""
91
+
92
+
93
+ # ------------------------------------------------------------- formatting
94
+
95
+ def _fmt(value, digits: int = 2) -> str:
96
+ v = _num(value)
97
+ if v is None:
98
+ return ""
99
+ return f"{v:,.{digits}f}" if abs(v) >= 1 else f"{v:.4f}"
100
+
101
+
102
+ def _pct(value, signed: bool = True) -> str:
103
+ v = _num(value)
104
+ if v is None:
105
+ return ""
106
+ return f"{v:+.1f}%" if signed else f"{v:.1f}%"
107
+
108
+
109
+ def _ratio_pct(value) -> str:
110
+ """0.12 -> 12% (risk metrics and yields come as fractions)."""
111
+ v = _num(value)
112
+ return "" if v is None else f"{v * 100:.0f}%"
113
+
114
+
115
+ def _word(value) -> str:
116
+ return str(value or "").replace("_", " ").strip().capitalize()
117
+
118
+
119
+ # ------------------------------------------------------------- building
120
+
121
+ def _extras(rows: list[dict]) -> dict:
122
+ """symbol -> collect_extras() for the file's per-stock data (parallel)."""
123
+ from .analysis_run import collect_extras
124
+
125
+ good = [r for r in rows if "error" not in r]
126
+
127
+ def one(r):
128
+ try:
129
+ return r["symbol"], collect_extras(r["symbol"], r["market"], FILE_EXTRAS)
130
+ except Exception as exc:
131
+ logger.warning("Extras for %s unavailable: %s", r["symbol"], exc)
132
+ return r["symbol"], {}
133
+
134
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
135
+ return dict(pool.map(one, good))
136
+
137
+
138
+ def _results_like(rows: list[dict]) -> list[dict]:
139
+ """Smart rows in the shape the normal report helpers take."""
140
+ return [{"symbol": r["symbol"], "market": r["market"],
141
+ "quote": {"price": r.get("price"), "change_percent": r.get("change_percent")},
142
+ "dashboard": {}} for r in rows if "error" not in r]
143
+
144
+
145
+ def _call(r: dict) -> str:
146
+ """The indicators' reading (bullish / neutral / bearish), never a call."""
147
+ from .smart_report import reading_label
148
+
149
+ return reading_label(r) + (AI_TAG if r.get("ai") else "")
150
+
151
+
152
+ def _why(r: dict) -> str:
153
+ return r.get("ai_summary") or ", ".join(r.get("reasons") or [])
154
+
155
+
156
+ def _valuation(r: dict, ex: dict) -> str:
157
+ """Valuation against the sector median, as a comparison: an
158
+ "undervalued / overvalued" verdict would be a call."""
159
+ val = ex.get("valuation") or {}
160
+ position = _num(val.get("position"))
161
+ sector = val.get("sector") or "sector"
162
+ if position:
163
+ gap = (position - 1) * 100
164
+ where = (f"in line with the {sector} median" if abs(gap) < 1 else
165
+ f"{abs(gap):.0f}% {'below' if gap < 0 else 'above'} the {sector} median")
166
+ return f"Priced {where}"
167
+ pe = _num(r.get("pe"))
168
+ return f"P/E {pe:.1f}; no sector median to compare" if pe else ""
169
+
170
+
171
+ def _stock_table(rows: list[dict], extras: dict) -> Table:
172
+ from .report import CURRENCY_SYMBOLS, MARKET_NAMES
173
+
174
+ multi = len({r["market"] for r in rows}) > 1
175
+ cols = (["Stock", "Name"] + (["Market"] if multi else []) + ["Indicators", "Price", "Day",
176
+ "Valuation vs sector", "What stands out"])
177
+ table = Table("Stocks at a glance", cols, note=(
178
+ "Indicators: how trend, momentum, the price's place in its range and valuation lean today. Marked AI: "
179
+ "an AI summary of the figures; otherwise what the data flags. A description, not a recommendation."))
180
+ for r in rows:
181
+ if "error" in r:
182
+ table.rows.append([r["symbol"], f"no data: {str(r['error'])[:60]}"] + [""] * (len(cols) - 2))
183
+ continue
184
+ cur = CURRENCY_SYMBOLS.get(r["market"], "")
185
+ table.rows.append([r["symbol"], r.get("name") or ""] + ([MARKET_NAMES.get(r["market"], r["market"])] if multi else [])
186
+ + [_call(r), f"{cur}{_fmt(r.get('price'))}", _pct(r.get("change_percent")),
187
+ _valuation(r, extras.get(r["symbol"]) or {}), _why(r)])
188
+ return table
189
+
190
+
191
+ def _band_position(r: dict) -> str:
192
+ """Where the price sits in its normal (20-day) range: 0% bottom, 100% top."""
193
+ low, high, price = _num(r.get("normal_low")), _num(r.get("normal_high")), _num(r.get("price"))
194
+ if None in (low, high, price) or high <= low:
195
+ return ""
196
+ pos = (price - low) / (high - low) * 100
197
+ return f"{pos:.0f}%" + (" (above it)" if pos > 100 else " (below it)" if pos < 0 else "")
198
+
199
+
200
+ def _rsi_word(rsi) -> str:
201
+ v = _num(rsi)
202
+ if v is None:
203
+ return ""
204
+ return f"{v:.0f}" + (" (fast rise)" if v >= 70 else " (fast fall)" if v <= 30 else "")
205
+
206
+
207
+ def _range_position(r: dict) -> str:
208
+ """Where today's price sits between the 52-week low (0%) and high (100%)."""
209
+ low, high, price = _num(r.get("low_52w")), _num(r.get("high_52w")), _num(r.get("price"))
210
+ if None in (low, high, price) or high <= low:
211
+ return ""
212
+ return f"{(price - low) / (high - low) * 100:.0f}% of range"
213
+
214
+
215
+ def _card(r: dict, ex: dict) -> StockCard:
216
+ """The important figures for one stock, the same for every report."""
217
+ from .report import CURRENCY_SYMBOLS
218
+
219
+ cur = CURRENCY_SYMBOLS.get(r["market"], "")
220
+ facts: list[tuple[str, str]] = []
221
+
222
+ def add(label: str, value) -> None:
223
+ if value not in (None, "", "–", " ()"):
224
+ facts.append((label, str(value)))
225
+
226
+ add("Normal range", f"{_fmt(r.get('normal_low'))}–{_fmt(r.get('normal_high'))}")
227
+ add("Position in normal range", _band_position(r))
228
+ add("Expected 1-week range", f"{_fmt(r.get('week_low'))}–{_fmt(r.get('week_high'))}")
229
+ add("52-week low", _fmt(r.get("low_52w")))
230
+ add("52-week high", _fmt(r.get("high_52w")))
231
+ add("In 52-week range", _range_position(r))
232
+ add("Trend", _word(r.get("trend")))
233
+ add("RSI (14)", _rsi_word(r.get("rsi")))
234
+ add("P/E", _fmt(r.get("pe"), 1))
235
+ add("Volatility (yearly)", _ratio_pct(r.get("volatility")))
236
+ add("Max drawdown", _ratio_pct(r.get("max_drawdown")))
237
+ add("Average daily move (ATR)", _fmt(r.get("atr")))
238
+ ev = ex.get("events") or {}
239
+ add("Next results", str(ev.get("earnings_date") or "")[:10])
240
+ add("Ex-dividend", str(ev.get("ex_dividend_date") or "")[:10])
241
+
242
+ summary = []
243
+ if _why(r):
244
+ label = "AI summary of the figures" if r.get("ai") else "What stands out"
245
+ summary.append((label, _why(r), "accent"))
246
+ if _valuation(r, ex):
247
+ summary.append(("Valuation vs sector", _valuation(r, ex), "accent"))
248
+ notes: list = [summary] if summary else []
249
+ return StockCard(symbol=r["symbol"], name=r.get("name") or "", call=_call(r),
250
+ price=f"{cur}{_fmt(r.get('price'))}", change=_pct(r.get("change_percent")),
251
+ facts=facts, notes=notes)
252
+
253
+
254
+ def _highlights(rows: list[dict], good: list[dict]) -> list[Tile]:
255
+ ai = sum(1 for r in good if r.get("ai"))
256
+ tiles = [Tile("Stocks", str(len(rows)), f"{ai} reviewed by the AI" if ai else "computed readings", "accent")]
257
+ if good:
258
+ counts = {k: sum(1 for r in good if r.get("reading") == k) for k in ("positive", "mixed", "negative")}
259
+ tiles += [Tile("Bullish", str(counts["positive"]), "indicators lean up", "up"),
260
+ Tile("Neutral", str(counts["mixed"]), "indicators split", "warn"),
261
+ Tile("Bearish", str(counts["negative"]), "indicators lean down", "down")]
262
+ moves = [r for r in good if _num(r.get("change_percent")) is not None]
263
+ if moves:
264
+ best = max(moves, key=lambda r: r["change_percent"])
265
+ worst = min(moves, key=lambda r: r["change_percent"])
266
+ tiles.append(Tile("Best today", _pct(best["change_percent"]), best["symbol"], "up" if best["change_percent"] >= 0 else "down"))
267
+ tiles.append(Tile("Worst today", _pct(worst["change_percent"]), worst["symbol"], "down" if worst["change_percent"] < 0 else "up"))
268
+ return tiles
269
+
270
+
271
+ def _guide() -> Table:
272
+ """"How to read this report": every term the file uses, in plain words."""
273
+ terms = [
274
+ ("Indicators", "▲ Bullish, ◆ Neutral or ▼ Bearish: how most of trend, momentum (RSI), "
275
+ "the price's place in its normal range and valuation lean today. It describes the data "
276
+ "and is not a recommendation to buy, sell or hold."),
277
+ ("Valuation vs sector", "How the stock's P/E, P/B and P/S compare with the median of its sector among "
278
+ "the market's largest companies. A lower or higher price than peers can have "
279
+ "good reasons; it is a comparison, not a verdict."),
280
+ ]
281
+ terms += [
282
+ ("Normal range", "Where the price usually trades: the 20-day Bollinger band. Outside it means an unusually "
283
+ "large move for recent weeks."),
284
+ ("1-week range", "Where about 2 of 3 weeks end, from recent volatility. Not a forecast or a target."),
285
+ ("52-week range", "The lowest and highest price of the past year; 0% of range = at the low, 100% = at the high."),
286
+ ("Trend", "Uptrend: price above its 50- and 200-day averages; downtrend: below both; sideways: neither."),
287
+ ("RSI", "Momentum from 0 to 100. Above 70 the price rose fast recently, below 30 it fell fast."),
288
+ ("Volatility", "How much the price swings in a year; 25% means a typical yearly move of about a quarter of "
289
+ "the price."),
290
+ ("Max drawdown", "The deepest fall from a high during the past year."),
291
+ ("AI summary of the figures", "Written by an AI model from the figures above. It can be wrong."),
292
+ ]
293
+ return Table("How to read this report", ["Term", "What it means"], [list(t) for t in terms])
294
+
295
+
296
+ def build_document(rows: list[dict], prefs, config, now: datetime, title: str) -> Document:
297
+ """The report file: the same core set every time (see the module doc)."""
298
+ from .smart_report import READING_LABELS, READING_NOTE, reading_label
299
+
300
+ good = [r for r in rows if "error" not in r]
301
+ ai_count = sum(1 for r in good if r.get("ai"))
302
+ doc = Document(title, f"{now:%d %b %Y, %H:%M %Z}".strip() + f" · {len(rows)} stocks"
303
+ + (f" · {ai_count} reviewed by the AI" if ai_count else ""),
304
+ notice=READING_NOTE)
305
+ results = _results_like(rows)
306
+
307
+ try:
308
+ from .analysis_run import market_overview
309
+ from .report import market_review
310
+
311
+ overview = market_overview(r["market"] for r in good)
312
+ if overview:
313
+ take = market_review(overview, results, config)
314
+ doc.blocks.append(Table("Market overview", ["Index", "Level", "Day"],
315
+ [[i["name"], _fmt(i.get("price")), _pct(i.get("change_percent"))]
316
+ for i in overview], note=take or ""))
317
+ except Exception as exc:
318
+ logger.warning("Market overview unavailable for the report file: %s", exc)
319
+
320
+ if good:
321
+ groups = {k: [r["symbol"] + (AI_TAG if r.get("ai") else "") for r in good if r.get("reading") == k]
322
+ for k in READING_LABELS}
323
+ doc.blocks.append(Table("What the indicators show", ["Indicators", "Stocks", "Count"],
324
+ [[reading_label({"reading": k}), ", ".join(v) or "—", len(v)] for k, v in groups.items()],
325
+ note="Grouped by how most indicators (trend, momentum, place in the range, valuation) "
326
+ "lean today. This sorts the data; it does not say what to do."))
327
+
328
+ if getattr(prefs, "portfolios", None):
329
+ try:
330
+ from .analysis_run import portfolio_snapshots
331
+
332
+ ports = portfolio_snapshots(prefs.portfolios, results, config)
333
+ if ports:
334
+ doc.blocks.append(Table("Your portfolio", ["Portfolio", "Value", "Today", "Today %", "Total gain", "Gain %", "Moved most"], [
335
+ [p["name"], f"{p.get('currency_symbol', '')}{_fmt(p.get('value'), 0)}", _fmt(p.get("day_change"), 0),
336
+ _pct(p.get("day_change_pct")), _fmt(p.get("gain"), 0), _pct(p.get("gain_pct")),
337
+ ", ".join(f"{m['symbol']} {_pct(m.get('change_pct'))}" for m in p.get("movers", []))]
338
+ for p in ports]))
339
+ except Exception as exc:
340
+ logger.warning("Portfolio unavailable for the report file: %s", exc)
341
+
342
+ extras = _extras(rows)
343
+ doc.blocks.append(_stock_table(rows, extras))
344
+
345
+ if good:
346
+ doc.blocks.append(_guide())
347
+ doc.cards = [_card(r, extras.get(r["symbol"]) or {}) for r in good]
348
+ doc.highlights = _highlights(rows, good)
349
+ return doc
350
+
351
+
352
+ # ------------------------------------------------------------- rendering
353
+
354
+ def to_pdf(doc: Document) -> bytes:
355
+ """A4 landscape: title band, summary tiles, the sections, a card per stock."""
356
+ from .smart_pdf import render
357
+
358
+ return render(doc, DISCLAIMER)
359
+
360
+
361
+ def _cell_value(value):
362
+ """'₹1,312.40' -> 1312.4, '+1.2%' -> (0.012, percent format), so Excel
363
+ sorts and filters numbers as numbers; anything else stays as it is."""
364
+ from .report import CURRENCY_SYMBOLS
365
+
366
+ if not isinstance(value, str) or not any(ch.isdigit() for ch in value): # also keeps "INF", "NAN" as text
367
+ return value, None
368
+ text = value.replace(",", "")
369
+ for sign in sorted(filter(None, CURRENCY_SYMBOLS.values()), key=len, reverse=True):
370
+ text = text.removeprefix(sign)
371
+ percent = text.endswith("%")
372
+ try:
373
+ number = float(text.rstrip("%"))
374
+ except ValueError:
375
+ return value, None
376
+ if percent:
377
+ return number / 100, "0.0%" if "." in text else "0%"
378
+ return number, "#,##0.00" if "." in text else "#,##0"
379
+
380
+
381
+ def to_xlsx(doc: Document) -> bytes:
382
+ """A styled workbook: a Summary sheet (title, summary figures, the
383
+ report-wide tables), "Stocks" to sort and filter, "Stock details" with
384
+ every ticked detail and the AI's notes per stock, and a sheet for any
385
+ other long table. Numbers are stored as numbers; gains are green,
386
+ losses red, calls coloured."""
387
+ from openpyxl import Workbook
388
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
389
+ from openpyxl.utils import get_column_letter
390
+
391
+ from .smart_pdf import tone_of
392
+
393
+ fill = lambda hex_: PatternFill("solid", fgColor=hex_) # noqa: E731
394
+ tone_font = {"up": "0B7A52", "down": "C02F2F", "warn": "9A6408", "accent": "1F3A8A"}
395
+ tone_fill = {"up": "E3F4EC", "down": "FBE7E7", "warn": "FDF3DC", "accent": "E8EDF8"}
396
+ thin = Side(style="thin", color="DCE0E8")
397
+ grid = Border(bottom=thin, top=thin, left=thin, right=thin)
398
+ head_font, head_fill = Font(bold=True, color="FFFFFF", size=10), fill("0F2A5C")
399
+ top_left = Alignment(vertical="top", wrap_text=True)
400
+ signed_pct = '[Color10]+0.0%;[Red]-0.0%;0.0%'
401
+
402
+ wb = Workbook()
403
+
404
+ def banner(ws, width: int) -> None:
405
+ ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=width)
406
+ ws.cell(1, 1, doc.title).font = Font(bold=True, size=16, color="FFFFFF")
407
+ ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=width)
408
+ ws.cell(2, 1, doc.subtitle).font = Font(italic=True, size=9, color="D6DEEE")
409
+ for row in (1, 2):
410
+ for col in range(1, width + 1):
411
+ ws.cell(row, col).fill = head_fill
412
+ ws.row_dimensions[1].height = 30
413
+ ws.cell(1, 1).alignment = Alignment(vertical="center", indent=1)
414
+ ws.cell(2, 1).alignment = Alignment(vertical="center", indent=1)
415
+
416
+ def style_value(cell, value, column: str) -> None:
417
+ number, number_format = _cell_value(value)
418
+ cell.value = number
419
+ cell.border = grid
420
+ cell.alignment = Alignment(vertical="top", wrap_text=isinstance(number, str) and len(number) > 40,
421
+ horizontal="right" if number_format else None)
422
+ tone = tone_of(value) if isinstance(value, str) else None
423
+ if number_format:
424
+ signed = isinstance(value, str) and value[:1] in "+-−" and number_format.endswith("%")
425
+ cell.number_format = signed_pct if signed else number_format
426
+ elif tone and column in ("Call", "Result", "Indicators"):
427
+ cell.font = Font(bold=True, color=tone_font[tone])
428
+ cell.fill = fill(tone_fill[tone])
429
+ cell.alignment = Alignment(horizontal="center", vertical="top")
430
+ elif tone in ("up", "down"):
431
+ cell.font = Font(color=tone_font[tone])
432
+
433
+ def put_table(ws, table: Table, row: int, zebra: bool = True) -> int:
434
+ for j, name in enumerate(table.columns, start=1):
435
+ c = ws.cell(row, j, name)
436
+ c.font, c.fill, c.border = head_font, head_fill, grid
437
+ c.alignment = Alignment(vertical="center", wrap_text=True)
438
+ ws.row_dimensions[row].height = 20
439
+ for i, values in enumerate(table.rows, start=1):
440
+ for j, (name, value) in enumerate(zip(table.columns, values), start=1):
441
+ c = ws.cell(row + i, j)
442
+ style_value(c, value, name)
443
+ if j == 1:
444
+ c.font = Font(bold=True, color="0F2A5C")
445
+ if zebra and i % 2 == 0 and c.fill.fgColor.rgb in (None, "00000000"):
446
+ c.fill = fill("F5F7FA")
447
+ row += len(table.rows) + 1
448
+ if table.note:
449
+ ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=max(len(table.columns), 1))
450
+ c = ws.cell(row, 1, table.note)
451
+ c.font, c.alignment = Font(italic=True, size=8, color="6B7280"), Alignment(wrap_text=True, vertical="top")
452
+ ws.row_dimensions[row].height = 14 * (1 + len(table.note) // 140)
453
+ row += 1
454
+ return row
455
+
456
+ def section_title(ws, row: int, title: str, width: int) -> int:
457
+ c = ws.cell(row, 1, title)
458
+ c.font = Font(bold=True, size=12, color="0F2A5C")
459
+ for col in range(1, max(width, 1) + 1):
460
+ ws.cell(row, col).border = Border(bottom=Side(style="medium", color="1F3A8A"))
461
+ return row + 1
462
+
463
+ def fit(ws, first_row: int, cap: int = 60, wide: Optional[set] = None) -> None:
464
+ for i, col in enumerate(ws.iter_cols(min_row=first_row, max_row=min(ws.max_row, 400)), start=1):
465
+ longest = max((len(str(c.value)) for c in col if c.value is not None and not
466
+ (isinstance(c.value, str) and len(c.value) > 140)), default=8)
467
+ width = 55 if wide and i in wide else min(max(longest + 3, 9), cap)
468
+ ws.column_dimensions[get_column_letter(i)].width = width
469
+
470
+ def page_setup(ws) -> None:
471
+ ws.page_setup.orientation = "landscape"
472
+ ws.page_setup.fitToWidth, ws.page_setup.fitToHeight = 1, 0
473
+ ws.sheet_properties.pageSetUpPr.fitToPage = True
474
+ ws.sheet_view.showGridLines = False
475
+
476
+ # ---- Summary
477
+ summary = wb.active
478
+ summary.title = "Summary"
479
+ small = [b for b in doc.blocks if not (isinstance(b, Table) and (b.title.startswith("Stocks") or len(b.rows) > 8))]
480
+ width = max([len(doc.highlights)] + [len(b.columns) for b in small if isinstance(b, Table)] + [6])
481
+ banner(summary, width)
482
+ row = 4
483
+ if doc.notice:
484
+ summary.merge_cells(start_row=row, start_column=1, end_row=row, end_column=width)
485
+ c = summary.cell(row, 1, doc.notice)
486
+ c.font = Font(bold=True, size=10, color="0F2A5C")
487
+ c.fill = fill("EEF3FB")
488
+ c.alignment = Alignment(wrap_text=True, vertical="center", indent=1)
489
+ c.border = Border(left=Side(style="thick", color="1F3A8A"))
490
+ summary.row_dimensions[row].height = 48
491
+ row += 2
492
+ if doc.highlights:
493
+ for j, t in enumerate(doc.highlights, start=1):
494
+ color = tone_font.get(t.tone, "0F2A5C")
495
+ for k, (value, font) in enumerate(((t.label.upper(), Font(bold=True, size=8, color="6B7280")),
496
+ (t.value, Font(bold=True, size=18, color=color)),
497
+ (t.sub, Font(size=8, color="6B7280")))):
498
+ c = summary.cell(row + k, j, value)
499
+ c.font, c.fill = font, fill("F7F9FC")
500
+ c.alignment = Alignment(horizontal="left", vertical="center", indent=1)
501
+ c.border = Border(left=Side(style="thick", color=color), top=thin if k == 0 else None,
502
+ bottom=thin if k == 2 else None, right=thin)
503
+ summary.row_dimensions[row + 1].height = 28
504
+ row += 4
505
+ for b in small:
506
+ row = section_title(summary, row, b.title, len(b.columns) if isinstance(b, Table) else width)
507
+ if isinstance(b, Table):
508
+ row = put_table(summary, b, row)
509
+ else:
510
+ for line in b.lines:
511
+ summary.cell(row, 1, line).alignment = Alignment(wrap_text=True)
512
+ row += 1
513
+ row += 1
514
+ c = summary.cell(row, 1, DISCLAIMER)
515
+ c.font = Font(italic=True, size=8, color="6B7280")
516
+ fit(summary, 4, cap=48)
517
+ page_setup(summary)
518
+
519
+ used = {"Summary"}
520
+
521
+ def sheet(title: str):
522
+ name = title[:31].replace("/", "-")
523
+ while name in used:
524
+ name = name[:28] + "_2"
525
+ used.add(name)
526
+ ws = wb.create_sheet(name)
527
+ page_setup(ws)
528
+ return ws
529
+
530
+ # ---- big tables (the stocks at a glance or a long track record)
531
+ for b in doc.blocks:
532
+ if b in small:
533
+ continue
534
+ ws = sheet("Stocks" if b.title.startswith("Stocks") else b.title)
535
+ end = put_table(ws, b, 1)
536
+ ws.freeze_panes = "B2"
537
+ if b.rows:
538
+ ws.auto_filter.ref = f"A1:{get_column_letter(len(b.columns))}{len(b.rows) + 1}"
539
+ fit(ws, 1)
540
+ if b.note and end:
541
+ ws.row_dimensions[end - 1].height = 28
542
+
543
+ # ---- Stock details: one row per stock with every ticked detail and the AI's notes
544
+ if doc.cards:
545
+ labels: list[str] = []
546
+ for card in doc.cards:
547
+ labels += [label for label, _ in card.facts if label not in labels]
548
+ # The AI's reasoning and the computed reasons share one column.
549
+ same = {"Why (computed)": "AI view"}
550
+ note_labels: list[str] = []
551
+ for card in doc.cards:
552
+ for group in card.notes:
553
+ note_labels += [same.get(label, label) for label, _, _ in group
554
+ if same.get(label, label) not in note_labels]
555
+ news = any(card.news for card in doc.cards)
556
+ columns = ["Stock", "Name", "Call", "Price", "Day"] + labels + note_labels + (["News"] if news else [])
557
+ rows = []
558
+ for card in doc.cards:
559
+ facts = dict(card.facts)
560
+ notes = {same.get(label, label): text for group in card.notes for label, text, _ in group}
561
+ rows.append([card.symbol, card.name, card.call, card.price, card.change]
562
+ + [facts.get(label, "") for label in labels]
563
+ + [notes.get(label, "") for label in note_labels]
564
+ + (["\n".join(card.news)] if news else []))
565
+ ws = sheet("Stock details")
566
+ put_table(ws, Table("Stock details", columns, rows), 1)
567
+ ws.freeze_panes = "B2"
568
+ ws.auto_filter.ref = f"A1:{get_column_letter(len(columns))}{len(rows) + 1}"
569
+ long_cols = {i for i, name in enumerate(columns, start=1) if name in note_labels or name == "News"}
570
+ fit(ws, 1, wide=long_cols)
571
+ for i in range(2, len(rows) + 2):
572
+ for j in long_cols:
573
+ ws.cell(i, j).alignment = top_left
574
+ if long_cols:
575
+ longest = max(len(str(ws.cell(i, j).value or "")) for j in long_cols)
576
+ ws.row_dimensions[i].height = min(max(15, 15 * (longest // 55 + 1)), 400)
577
+
578
+ out = io.BytesIO()
579
+ wb.save(out)
580
+ return out.getvalue()
581
+
582
+
583
+ def report_file(rows: list[dict], prefs, config, now: datetime, title: str) -> Optional[tuple[str, bytes]]:
584
+ """(filename, bytes) in the format the user picked, or None for "none"
585
+ or when building fails (the short message still goes out)."""
586
+ kind = FILE_TYPES.get(getattr(prefs, "smart_file", "pdf"))
587
+ if not kind or not rows:
588
+ return None
589
+ try:
590
+ doc = build_document(rows, prefs, config, now, title)
591
+ data = to_pdf(doc) if kind == "pdf" else to_xlsx(doc)
592
+ except Exception:
593
+ logger.exception("Smart report file could not be built")
594
+ return None
595
+ return f"MarketMyna-report-{now:%Y-%m-%d}.{kind}", data