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,425 @@
1
+ """PDF layout for the Smart messaging report file (marketmyna/services/smart_file.py).
2
+
3
+ Drawn with PyMuPDF directly rather than through its HTML Story, whose
4
+ tables size themselves to their content: here every table fills the page
5
+ width with measured columns, numbers line up on the right, and headers
6
+ repeat when a table runs onto the next page.
7
+
8
+ A4 landscape. Page one opens with a title band and summary tiles; then the
9
+ report-wide sections, the stock table, and one card per stock with the
10
+ details the user ticked. Every page has a footer with the page number.
11
+
12
+ Text is Helvetica; characters it lacks (₹, Chinese names, Devanagari) fall
13
+ back to MuPDF's built-in Noto / Droid fonts, one run at a time.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from functools import lru_cache
20
+
21
+ PAGE_W, PAGE_H = 842.0, 595.0 # A4 landscape, points
22
+ MARGIN = 32.0
23
+ CONTENT_W = PAGE_W - 2 * MARGIN
24
+ FOOTER_H = 26.0
25
+ BOTTOM = PAGE_H - MARGIN - FOOTER_H + 10
26
+
27
+ INK = (0.06, 0.08, 0.13)
28
+ MUTED = (0.42, 0.45, 0.52)
29
+ NAVY = (0.06, 0.16, 0.36)
30
+ ACCENT = (0.12, 0.23, 0.54)
31
+ LINE = (0.86, 0.88, 0.92)
32
+ ZEBRA = (0.965, 0.972, 0.984)
33
+ CARD_BG = (0.985, 0.988, 0.995)
34
+ WHITE = (1, 1, 1)
35
+ UP = (0.04, 0.48, 0.32)
36
+ DOWN = (0.75, 0.18, 0.18)
37
+ WARN = (0.72, 0.48, 0.05)
38
+ TONES = {"up": UP, "down": DOWN, "warn": WARN, "accent": ACCENT, None: INK, "": INK}
39
+
40
+ _SIGNED = re.compile(r"^[+\-−]\s?[\d.,]")
41
+ _NUMERIC = re.compile(r"^[^\w\s]{0,3}\$?[+\-−]?[\d.,]+\s?(%|×|pts)?$|^[\d.,]+–[\d.,]+$")
42
+
43
+
44
+ @lru_cache(maxsize=None)
45
+ def _fonts(bold: bool):
46
+ import fitz
47
+
48
+ main = fitz.Font("hebo" if bold else "helv")
49
+ fallbacks = []
50
+ for make in (lambda: fitz.Font(script=0), lambda: fitz.Font("cjk"), lambda: fitz.Font(script=9)):
51
+ try:
52
+ fallbacks.append(make())
53
+ except Exception:
54
+ pass
55
+ return [main] + fallbacks
56
+
57
+
58
+ def _runs(text: str, bold: bool):
59
+ """(font, text) runs: each character in the first font that has it."""
60
+ fonts = _fonts(bold)
61
+ runs: list = []
62
+ for ch in text:
63
+ font = next((f for f in fonts if f.has_glyph(ord(ch))), fonts[0])
64
+ if runs and runs[-1][0] is font:
65
+ runs[-1][1] += ch
66
+ else:
67
+ runs.append([font, ch])
68
+ return runs
69
+
70
+
71
+ def text_width(text: str, size: float, bold: bool = False) -> float:
72
+ return sum(font.text_length(part, fontsize=size) for font, part in _runs(text, bold))
73
+
74
+
75
+ def wrap(text: str, width: float, size: float, bold: bool = False) -> list[str]:
76
+ """Lines no wider than `width`; long words are broken where they must be."""
77
+ lines: list[str] = []
78
+ for para in str(text).split("\n"):
79
+ line = ""
80
+ for word in para.split():
81
+ candidate = f"{line} {word}" if line else word
82
+ if text_width(candidate, size, bold) <= width:
83
+ line = candidate
84
+ continue
85
+ if line:
86
+ lines.append(line)
87
+ while text_width(word, size, bold) > width and len(word) > 1:
88
+ cut = len(word)
89
+ while cut > 1 and text_width(word[:cut], size, bold) > width:
90
+ cut -= 1
91
+ lines.append(word[:cut])
92
+ word = word[cut:]
93
+ line = word
94
+ lines.append(line)
95
+ return lines or [""]
96
+
97
+
98
+ def tone_of(value: str):
99
+ """Green for a signed gain, red for a signed loss, the call colours."""
100
+ v = str(value).strip()
101
+ word = v.split(" · ")[0].upper().lstrip("▲◆▼ ")
102
+ if word in ("BUY", "STRONG BUY", "RIGHT", "MOSTLY POSITIVE"):
103
+ return "up"
104
+ if word in ("SELL", "STRONG SELL", "WRONG", "MOSTLY NEGATIVE"):
105
+ return "down"
106
+ if word in ("HOLD", "MIXED"):
107
+ return "warn"
108
+ if _SIGNED.match(v):
109
+ return "down" if v[0] in "-−" else "up"
110
+ return None
111
+
112
+
113
+ class Canvas:
114
+ def __init__(self, title: str, subtitle: str):
115
+ import fitz
116
+
117
+ self.doc = fitz.open()
118
+ self.title, self.subtitle = title, subtitle
119
+ self.page = None
120
+ self.y = 0.0
121
+ self.new_page(first=True)
122
+
123
+ # ---------------------------------------------------------- primitives
124
+
125
+ def new_page(self, first: bool = False) -> None:
126
+ self.page = self.doc.new_page(width=PAGE_W, height=PAGE_H)
127
+ if first:
128
+ self._band()
129
+ else:
130
+ self.text(MARGIN, MARGIN + 8, self.title, 8, bold=True, color=MUTED)
131
+ self.page.draw_line((MARGIN, MARGIN + 13), (PAGE_W - MARGIN, MARGIN + 13), color=LINE, width=0.6)
132
+ self.y = MARGIN + 26
133
+
134
+ def ensure(self, height: float) -> bool:
135
+ """Start a new page unless `height` fits; True when it did."""
136
+ if self.y + height > BOTTOM:
137
+ self.new_page()
138
+ return True
139
+ return False
140
+
141
+ def text(self, x: float, baseline: float, text: str, size: float, bold: bool = False, color=INK,
142
+ align: str = "left", width: float = 0.0) -> None:
143
+ import fitz
144
+
145
+ if not text:
146
+ return
147
+ if align != "left":
148
+ w = text_width(text, size, bold)
149
+ x += (width - w) if align == "right" else (width - w) / 2
150
+ writer = fitz.TextWriter(self.page.rect)
151
+ for font, part in _runs(text, bold):
152
+ writer.append((x, baseline), part, font=font, fontsize=size)
153
+ x += font.text_length(part, fontsize=size)
154
+ writer.write_text(self.page, color=color)
155
+
156
+ def rect(self, x, y, w, h, fill=None, color=None, radius=None, width: float = 0.6) -> None:
157
+ import fitz
158
+
159
+ r = fitz.Rect(x, y, x + w, y + h)
160
+ if radius:
161
+ self.page.draw_rect(r, color=color, fill=fill, width=width, radius=radius)
162
+ else:
163
+ self.page.draw_rect(r, color=color, fill=fill, width=width)
164
+
165
+ def badge(self, x: float, baseline: float, label: str, tone, size: float = 7.0) -> float:
166
+ """A filled pill with white text; returns its width."""
167
+ w = text_width(label, size, True) + 8
168
+ self.rect(x, baseline - size - 1.5, w, size + 5, fill=TONES.get(tone, ACCENT), radius=0.25)
169
+ self.text(x + 4, baseline + 0.5, label, size, bold=True, color=WHITE)
170
+ return w
171
+
172
+ def paragraph(self, x: float, text: str, width: float, size: float = 8.0, color=INK,
173
+ bold: bool = False, leading: float = 1.35) -> None:
174
+ for line in wrap(text, width, size, bold):
175
+ self.ensure(size * leading)
176
+ self.y += size * leading
177
+ self.text(x, self.y - size * 0.3, line, size, bold=bold, color=color)
178
+
179
+ # ---------------------------------------------------------- blocks
180
+
181
+ def _band(self) -> None:
182
+ self.rect(0, 0, PAGE_W, 74, fill=NAVY)
183
+ self.rect(0, 74, PAGE_W, 3, fill=ACCENT)
184
+ self.text(MARGIN, 36, self.title, 19, bold=True, color=WHITE)
185
+ self.text(MARGIN, 56, self.subtitle, 9, color=(0.78, 0.83, 0.92))
186
+ self.text(MARGIN, 36, "MarketMyna", 11, bold=True, color=WHITE, align="right", width=CONTENT_W)
187
+ self.text(MARGIN, 52, "Smart report", 8, color=(0.78, 0.83, 0.92), align="right", width=CONTENT_W)
188
+ self.y = 92
189
+
190
+ def tiles(self, tiles) -> None:
191
+ if not tiles:
192
+ return
193
+ gap, h = 10.0, 50.0
194
+ w = (CONTENT_W - gap * (len(tiles) - 1)) / len(tiles)
195
+ for i, t in enumerate(tiles):
196
+ x = MARGIN + i * (w + gap)
197
+ self.rect(x, self.y, w, h, fill=CARD_BG, color=LINE, radius=0.08)
198
+ self.rect(x, self.y + 6, 3, h - 12, fill=TONES.get(t.tone, ACCENT))
199
+ self.text(x + 12, self.y + 15, t.label.upper(), 6.5, bold=True, color=MUTED)
200
+ self.text(x + 12, self.y + 33, t.value, 16, bold=True, color=TONES.get(t.tone, INK) if t.tone else INK)
201
+ if t.sub:
202
+ self.text(x + 12, self.y + 44, wrap(t.sub, w - 18, 6.5)[0], 6.5, color=MUTED)
203
+ self.y += h + 6
204
+
205
+ def notice(self, text: str) -> None:
206
+ """A highlighted box: what the report is and is not."""
207
+ lines = wrap(text, CONTENT_W - 120, 8.5)
208
+ h = len(lines) * 12 + 18
209
+ self.ensure(h + 8)
210
+ self.y += 4
211
+ self.rect(MARGIN, self.y, CONTENT_W, h, fill=(0.93, 0.95, 0.99), color=(0.78, 0.84, 0.95), radius=0.06)
212
+ self.rect(MARGIN, self.y, 4, h, fill=ACCENT)
213
+ self.text(MARGIN + 14, self.y + 16, "PLEASE NOTE", 7.5, bold=True, color=ACCENT)
214
+ for k, line in enumerate(lines):
215
+ self.text(MARGIN + 100, self.y + 16 + k * 12, line, 8.5, color=INK)
216
+ self.y += h + 4
217
+
218
+ def heading(self, title: str, caption: str = "") -> None:
219
+ self.ensure(60)
220
+ self.y += 16
221
+ self.text(MARGIN, self.y, title, 12, bold=True, color=NAVY)
222
+ if caption:
223
+ self.text(MARGIN, self.y, caption, 7.5, color=MUTED, align="right", width=CONTENT_W)
224
+ self.y += 5
225
+ self.page.draw_line((MARGIN, self.y), (MARGIN + 36, self.y), color=ACCENT, width=2)
226
+ self.page.draw_line((MARGIN + 36, self.y), (PAGE_W - MARGIN, self.y), color=LINE, width=0.6)
227
+ self.y += 6
228
+
229
+ def table(self, columns: list[str], rows: list[list], note: str = "", size: float = 7.5) -> None:
230
+ pad = 4.0
231
+ cells = [[str(v) if v is not None else "" for v in row] for row in rows]
232
+ numeric = [bool(col_vals) and all(_NUMERIC.match(v) for v in col_vals)
233
+ for col_vals in ([r[i] for r in cells if i < len(r) and r[i]] for i in range(len(columns)))]
234
+ badge_cols = {i for i, c in enumerate(columns) if c in ("Call", "Result", "Indicators")}
235
+ widths = self._widths(columns, cells, size, pad, numeric, badge_cols)
236
+ while widths is None and size > 5.5: # too many columns: a smaller font before squeezing words
237
+ size -= 0.5
238
+ widths = self._widths(columns, cells, size, pad, numeric, badge_cols)
239
+ if widths is None:
240
+ widths = self._widths(columns, cells, size, pad, numeric, badge_cols, force=True)
241
+ table_w = sum(widths)
242
+
243
+ def header() -> None:
244
+ h = max(len(wrap(c, widths[i] - 2 * pad, size, True)) for i, c in enumerate(columns)) * size * 1.25 + 2 * pad
245
+ self.rect(MARGIN, self.y, table_w, h, fill=NAVY)
246
+ x = MARGIN
247
+ for i, c in enumerate(columns):
248
+ for k, line in enumerate(wrap(c, widths[i] - 2 * pad, size, True)):
249
+ self.text(x + pad, self.y + pad + size * (1 + 1.25 * k) - 1, line, size, bold=True, color=WHITE,
250
+ align="right" if numeric[i] else "left", width=widths[i] - 2 * pad)
251
+ x += widths[i]
252
+ self.y += h
253
+
254
+ self.ensure(40)
255
+ header()
256
+ for n, row in enumerate(cells):
257
+ wrapped = [wrap(row[i] if i < len(row) else "", widths[i] - 2 * pad, size, i == 0)
258
+ for i in range(len(columns))]
259
+ h = max(1 if i in badge_cols else len(w) for i, w in enumerate(wrapped)) * size * 1.3 + 2 * pad
260
+ # The last row keeps the note under it on the same page.
261
+ tail = len(wrap(note, CONTENT_W, 7)) * 7 * 1.35 + 2 if note and n == len(cells) - 1 else 0
262
+ if self.ensure(h + tail):
263
+ header()
264
+ if n % 2:
265
+ self.rect(MARGIN, self.y, table_w, h, fill=ZEBRA)
266
+ x = MARGIN
267
+ for i, lines in enumerate(wrapped):
268
+ value = row[i] if i < len(row) else ""
269
+ tone = tone_of(value)
270
+ if i in badge_cols and tone and value:
271
+ self.badge(x + pad, self.y + pad + size, value, tone, size - 0.5)
272
+ else:
273
+ for k, line in enumerate(lines):
274
+ self.text(x + pad, self.y + pad + size * (1 + 1.3 * k) - 1, line, size,
275
+ bold=i == 0, color=TONES[tone] if tone and len(lines) == 1 else INK,
276
+ align="right" if numeric[i] else "left", width=widths[i] - 2 * pad)
277
+ x += widths[i]
278
+ self.page.draw_line((MARGIN, self.y + h), (MARGIN + table_w, self.y + h), color=LINE, width=0.4)
279
+ self.y += h
280
+ if note:
281
+ self.y += 2
282
+ self.paragraph(MARGIN, note, CONTENT_W, size=7, color=MUTED)
283
+
284
+ @staticmethod
285
+ def _widths(columns, cells, size, pad, numeric, badge_cols, force: bool = False):
286
+ """Column widths that never break a number, a badge or a word: the
287
+ natural width of each column, long text capped; spare room goes to
288
+ the text columns. None when the columns cannot fit at this size
289
+ (the caller tries a smaller font), unless `force`."""
290
+ natural, floor = [], []
291
+ for i, c in enumerate(columns):
292
+ values = [r[i] for r in cells if i < len(r) and r[i]]
293
+ if i in badge_cols:
294
+ longest = max([text_width(v, size - 0.5, True) + 8 for v in values] + [text_width(c, size, True)])
295
+ else:
296
+ longest = max([text_width(v, size, i == 0) for v in values] + [text_width(c, size, True)])
297
+ words = max([text_width(w, size, i == 0) for v in values for w in v.split()] +
298
+ [text_width(w, size, True) for w in c.split()] + [0])
299
+ natural.append(min(longest, 260) + 2 * pad + 1)
300
+ # Numbers and badges keep their full width; text may wrap down to its longest word.
301
+ floor.append(natural[-1] if numeric[i] or i in badge_cols else min(natural[-1], words + 2 * pad + 1))
302
+ total = sum(natural)
303
+ if total <= CONTENT_W:
304
+ texty = [i for i, w in enumerate(natural) if w > 90 and not numeric[i]]
305
+ if not texty: # a small table of figures: keep it compact, not stretched across the page
306
+ target = min(CONTENT_W, max(total * 1.35, CONTENT_W * 0.45))
307
+ return [w * target / total for w in natural]
308
+ spare = CONTENT_W - total
309
+ return [w + (spare / len(texty) if i in texty else 0) for i, w in enumerate(natural)]
310
+ if sum(floor) > CONTENT_W and not force:
311
+ return None
312
+ squeeze = (total - CONTENT_W) / max(sum(w - f for w, f in zip(natural, floor)), 1)
313
+ widths = [w - (w - f) * min(squeeze, 1) for w, f in zip(natural, floor)]
314
+ scale = CONTENT_W / sum(widths) if sum(widths) > CONTENT_W else 1
315
+ return [w * scale for w in widths]
316
+
317
+ def lines(self, lines: list[str]) -> None:
318
+ for line in lines:
319
+ self.paragraph(MARGIN, f"• {line}", CONTENT_W, size=8)
320
+
321
+ def card(self, card) -> None:
322
+ """One stock: header strip, a grid of facts, then the AI's notes side by side."""
323
+ inner = CONTENT_W - 24
324
+ cols = 6
325
+ cell_w = inner / cols
326
+ fact_rows = (len(card.facts) + cols - 1) // cols
327
+ est = 34 + fact_rows * 26 + sum(
328
+ max(len(wrap(t, inner / len(g) - 10, 7.5)) for _, t, _ in g) * 10.2 + 18 for g in card.notes
329
+ ) + (len(card.news) * 11 + 16 if card.news else 0)
330
+ self.ensure(min(est, BOTTOM - MARGIN - 40) + 12)
331
+ top = self.y + 6
332
+ # header strip
333
+ self.rect(MARGIN, top, CONTENT_W, 26, fill=(0.93, 0.95, 0.98), color=LINE, radius=0.12)
334
+ x = MARGIN + 12
335
+ self.text(x, top + 17, card.symbol, 11, bold=True, color=NAVY)
336
+ x += text_width(card.symbol, 11, True) + 8
337
+ if card.name and card.name.upper() != card.symbol.upper():
338
+ name = wrap(card.name, 260, 8.5)[0]
339
+ self.text(x, top + 17, name, 8.5, color=MUTED)
340
+ x += text_width(name, 8.5) + 10
341
+ if card.call:
342
+ self.badge(x, top + 16.5, card.call, tone_of(card.call) or "accent")
343
+ right = card.price + (f" {card.change}" if card.change else "")
344
+ if right.strip():
345
+ self.text(MARGIN, top + 17, card.change, 9, bold=True, color=TONES[tone_of(card.change)],
346
+ align="right", width=CONTENT_W - 12)
347
+ self.text(MARGIN, top + 17, card.price, 10, bold=True, color=INK, align="right",
348
+ width=CONTENT_W - 12 - (text_width(card.change, 9, True) + 10 if card.change else 0))
349
+ self.y = top + 30
350
+ # facts grid
351
+ for k in range(fact_rows):
352
+ self.ensure(26)
353
+ for j, (label, value) in enumerate(card.facts[k * cols:(k + 1) * cols]):
354
+ fx = MARGIN + 12 + j * cell_w
355
+ self.text(fx, self.y + 10, label.upper(), 6, bold=True, color=MUTED)
356
+ self.text(fx, self.y + 21, wrap(value, cell_w - 8, 8.5, True)[0], 8.5, bold=True,
357
+ color=TONES[tone_of(value)] if tone_of(value) in ("up", "down") else INK)
358
+ self.y += 26
359
+ # AI notes: each group's views side by side
360
+ for group in card.notes:
361
+ w = inner / len(group)
362
+ wrapped = [wrap(text, w - 10, 7.5) for _, text, _ in group]
363
+ self.ensure(30)
364
+ self.y += 6
365
+ self.page.draw_line((MARGIN + 12, self.y), (PAGE_W - MARGIN - 12, self.y), color=LINE, width=0.4)
366
+ start = self.y + 4
367
+ for j, (label, _, tone) in enumerate(group):
368
+ self.text(MARGIN + 12 + j * w, start + 8, label.upper(), 6.5, bold=True, color=TONES.get(tone, ACCENT))
369
+ self.y = start + 12
370
+ for k in range(max(len(w_) for w_ in wrapped)):
371
+ self.ensure(10.2)
372
+ self.y += 10.2
373
+ for j, lines in enumerate(wrapped):
374
+ if k < len(lines):
375
+ self.text(MARGIN + 12 + j * w, self.y - 2.5, lines[k], 7.5, color=INK)
376
+ if card.news:
377
+ self.ensure(24)
378
+ self.y += 6
379
+ self.page.draw_line((MARGIN + 12, self.y), (PAGE_W - MARGIN - 12, self.y), color=LINE, width=0.4)
380
+ self.text(MARGIN + 12, self.y + 12, "NEWS", 6.5, bold=True, color=ACCENT)
381
+ self.y += 14
382
+ for item in card.news:
383
+ for line in wrap(f"• {item}", inner, 7.5):
384
+ self.ensure(10.5)
385
+ self.y += 10.5
386
+ self.text(MARGIN + 12, self.y - 2.5, line, 7.5, color=INK)
387
+ self.y += 8
388
+ self.page.draw_line((MARGIN, self.y), (PAGE_W - MARGIN, self.y), color=LINE, width=0.8)
389
+ self.y += 4
390
+
391
+ def finish(self, disclaimer: str) -> bytes:
392
+ total = len(self.doc)
393
+ for n, page in enumerate(self.doc, start=1):
394
+ self.page = page
395
+ y = PAGE_H - MARGIN + 6
396
+ page.draw_line((MARGIN, y - 12), (PAGE_W - MARGIN, y - 12), color=LINE, width=0.5)
397
+ self.text(MARGIN, y, disclaimer, 6.5, color=MUTED)
398
+ self.text(MARGIN, y, f"Page {n} of {total}", 6.5, bold=True, color=MUTED, align="right", width=CONTENT_W)
399
+ data = self.doc.tobytes(garbage=3, deflate=True)
400
+ self.doc.close()
401
+ return data
402
+
403
+
404
+ def render(doc, disclaimer: str) -> bytes:
405
+ """The Document (smart_file.Document) as PDF bytes."""
406
+ from .smart_file import Table
407
+
408
+ canvas = Canvas(doc.title, doc.subtitle)
409
+ canvas.tiles(doc.highlights)
410
+ if doc.notice:
411
+ canvas.notice(doc.notice)
412
+ for block in doc.blocks:
413
+ canvas.heading(block.title, getattr(block, "caption", ""))
414
+ if isinstance(block, Table):
415
+ columns = block.pdf_columns or block.columns
416
+ keep = [block.columns.index(c) for c in columns]
417
+ canvas.table(columns, [[row[i] for i in keep] for row in block.rows], block.note)
418
+ else:
419
+ canvas.lines(block.lines)
420
+ if doc.cards:
421
+ canvas.new_page()
422
+ canvas.heading("Stock details", "The key figures for every stock")
423
+ for card in doc.cards:
424
+ canvas.card(card)
425
+ return canvas.finish(disclaimer)