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,398 @@
1
+ """Ready-made fundamental screens (OpenBB equity.discovery: undervalued large
2
+ caps, growth at a reasonable price, ...) built on free data.
3
+
4
+ The universe is the largest stocks of a market by market cap from our own
5
+ listings; each is enriched with Yahoo fundamentals (shared 6-hour cache with
6
+ the Compare page) and checked against the preset's rules. The first run of a
7
+ market takes ~20-40 s; later presets reuse the cache.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from typing import Callable, Optional
14
+
15
+ from ..core.numbers import finite as _f
16
+ from ..core.numbers import median as _median
17
+
18
+ UNIVERSE = 200
19
+ LARGE = 100 # "large cap" = top 100 by market cap in that market
20
+
21
+ # field -> (yahoo key, transform)
22
+ FIELDS: dict[str, tuple[str, Callable[[float], float]]] = {
23
+ "pe": ("trailingPE", lambda v: v),
24
+ "forward_pe": ("forwardPE", lambda v: v),
25
+ "pb": ("priceToBook", lambda v: v),
26
+ "peg": ("trailingPegRatio", lambda v: v),
27
+ "roe": ("returnOnEquity", lambda v: v * 100),
28
+ "debt_equity": ("debtToEquity", lambda v: v / 100),
29
+ "dividend_yield": ("dividendYield", lambda v: v), # Yahoo already gives percent
30
+ "payout": ("payoutRatio", lambda v: v * 100),
31
+ "earnings_growth": ("earningsGrowth", lambda v: v * 100),
32
+ "revenue_growth": ("revenueGrowth", lambda v: v * 100),
33
+ "operating_margin": ("operatingMargins", lambda v: v * 100),
34
+ # Extra fields for custom screens (the presets don't use them).
35
+ "ps": ("priceToSalesTrailing12Months", lambda v: v),
36
+ "ev_ebitda": ("enterpriseToEbitda", lambda v: v),
37
+ "ev_revenue": ("enterpriseToRevenue", lambda v: v),
38
+ "roa": ("returnOnAssets", lambda v: v * 100),
39
+ "gross_margin": ("grossMargins", lambda v: v * 100),
40
+ "ebitda_margin": ("ebitdaMargins", lambda v: v * 100),
41
+ "profit_margin": ("profitMargins", lambda v: v * 100),
42
+ "earnings_q_growth": ("earningsQuarterlyGrowth", lambda v: v * 100),
43
+ "avg_dividend_yield_5y": ("fiveYearAvgDividendYield", lambda v: v), # percent already
44
+ "current_ratio": ("currentRatio", lambda v: v),
45
+ "quick_ratio": ("quickRatio", lambda v: v),
46
+ "beta": ("beta", lambda v: v),
47
+ "change_52w": ("52WeekChange", lambda v: v * 100),
48
+ "analyst_rating": ("recommendationMean", lambda v: v),
49
+ "analysts": ("numberOfAnalystOpinions", lambda v: v),
50
+ "insiders": ("heldPercentInsiders", lambda v: v * 100),
51
+ "institutions": ("heldPercentInstitutions", lambda v: v * 100),
52
+ }
53
+
54
+ LABELS = {
55
+ "pe": "P/E", "forward_pe": "Fwd P/E", "pb": "P/B", "peg": "PEG", "roe": "ROE %", "debt_equity": "Debt/Eq",
56
+ "dividend_yield": "Div yield %", "payout": "Payout %", "earnings_growth": "EPS growth %",
57
+ "revenue_growth": "Rev growth %", "operating_margin": "Op margin %", "from_high": "From 52w high %",
58
+ "from_low": "Above 52w low %",
59
+ "sector": "Sector", "sector_pe": "Sector P/E", "pe_vs_sector": "P/E × sector", "pb_vs_sector": "P/B × sector",
60
+ "peg_calc": "PEG", "growth_short": "earnings growth too low for the P/E (PEG > 2 or shrinking)",
61
+ "ps": "P/S", "ev_ebitda": "EV/EBITDA", "ev_revenue": "EV/Revenue", "earnings_yield": "Earnings yield %",
62
+ "fcf_yield": "FCF yield %", "roa": "ROA %", "gross_margin": "Gross margin %", "ebitda_margin": "EBITDA margin %",
63
+ "profit_margin": "Net margin %", "earnings_q_growth": "Quarterly EPS growth %", "forward_eps_growth": "Fwd EPS growth %",
64
+ "avg_dividend_yield_5y": "5y avg div yield %", "current_ratio": "Current ratio", "quick_ratio": "Quick ratio",
65
+ "beta": "Beta", "change_52w": "1y return %", "market_cap": "Market cap", "price": "Price",
66
+ "target_upside": "Target upside %", "analyst_rating": "Analyst rating", "analysts": "Analysts",
67
+ "insiders": "Insiders %", "institutions": "Institutions %",
68
+ }
69
+
70
+ # Custom-screen catalogue: group -> metrics, with a hint for the builder.
71
+ CATALOGUE: list[tuple[str, list[tuple[str, str]]]] = [
72
+ ("Valuation", [("pe", "Price / trailing earnings per share"), ("forward_pe", "Price / next year's expected earnings"),
73
+ ("pb", "Price / book value per share"), ("ps", "Market cap / yearly revenue"),
74
+ ("peg", "P/E / expected earnings growth"), ("ev_ebitda", "Enterprise value / EBITDA"),
75
+ ("ev_revenue", "Enterprise value / revenue"), ("earnings_yield", "Earnings / price, the P/E upside down"),
76
+ ("fcf_yield", "Free cash flow / market cap")]),
77
+ ("Profitability", [("roe", "Profit / shareholders' equity"), ("roa", "Profit / total assets"),
78
+ ("gross_margin", "Gross profit / revenue"), ("operating_margin", "Operating profit / revenue"),
79
+ ("ebitda_margin", "EBITDA / revenue"), ("profit_margin", "Net profit / revenue")]),
80
+ ("Growth", [("revenue_growth", "Latest quarter vs the same quarter a year earlier"),
81
+ ("earnings_growth", "Latest quarter EPS vs a year earlier"),
82
+ ("earnings_q_growth", "Latest quarter net profit vs a year earlier"),
83
+ ("forward_eps_growth", "Next year's expected EPS vs the last 12 months")]),
84
+ ("Dividends", [("dividend_yield", "Yearly dividend / price"), ("avg_dividend_yield_5y", "Average yield over 5 years"),
85
+ ("payout", "Share of profit paid as dividends")]),
86
+ ("Balance sheet", [("debt_equity", "Total debt / equity"), ("current_ratio", "Current assets / current liabilities"),
87
+ ("quick_ratio", "Current assets minus stock / current liabilities")]),
88
+ ("Price & size", [("market_cap", "In the listing's currency"), ("price", "Last price"),
89
+ ("change_52w", "Price change over the past year"), ("from_high", "Distance below the 52-week high (negative)"),
90
+ ("from_low", "Distance above the 52-week low"), ("beta", "Moves vs the market: 1 = same, above 1 = swings more")]),
91
+ ("Analysts", [("target_upside", "Average target price vs the current price"),
92
+ ("analyst_rating", "1 = strong buy, 3 = hold, 5 = sell"), ("analysts", "Analysts covering the stock")]),
93
+ ("Ownership", [("insiders", "Shares held by promoters / insiders"), ("institutions", "Shares held by institutions")]),
94
+ ]
95
+ CUSTOM_FIELDS = {k for _, items in CATALOGUE for k, _ in items}
96
+ # Ratios whose sector median only means something over positive values.
97
+ POSITIVE_ONLY = {"pe", "forward_pe", "pb", "ps", "peg", "ev_ebitda", "ev_revenue"}
98
+ CUSTOM_OPS = (">", "<", "between", "top", "bottom", "below_sector", "above_sector")
99
+ MAX_RULES = 12
100
+
101
+ # Ratios the feed works out from statements in the reporting currency against
102
+ # a price in the listing currency (TSM: TWD accounts, USD shares), so they are
103
+ # wrong by the exchange rate whenever the two differ.
104
+ MIXED_CURRENCY = ("pb", "ps", "ev_ebitda", "ev_revenue", "fcf_yield")
105
+
106
+ SECTOR_MIN_PEERS = 5 # profitable companies a sector needs before its median means anything
107
+
108
+ # key: title, description, large caps only?, [(field, op, value)], sort (field, descending), columns
109
+ PRESETS = {
110
+ "undervalued_large": ("Low-multiple large caps", "Large, profitable companies with P/E under 18 and P/B under 3",
111
+ True, [("pe", "between", (0, 18)), ("pb", "<", 3), ("roe", ">", 12), ("earnings_growth", ">", 0)],
112
+ ("pe", False), ["pe", "pb", "roe", "earnings_growth", "dividend_yield"]),
113
+ "garp": ("Growth at a modest P/E", "EPS growth over 15% and revenue growth over 8% with P/E under 30",
114
+ False, [("earnings_growth", ">", 15), ("revenue_growth", ">", 8), ("pe", "between", (0, 30)), ("roe", ">", 15),
115
+ ("debt_equity", "<", 1)],
116
+ ("earnings_growth", True), ["pe", "earnings_growth", "revenue_growth", "roe", "debt_equity"]),
117
+ "dividend": ("High dividend, low debt", "Dividend yield over 3%, debt under half of equity, payout under 85%",
118
+ False, [("dividend_yield", ">", 3), ("debt_equity", "<", 0.5), ("payout", "between", (0, 85))],
119
+ ("dividend_yield", True), ["dividend_yield", "payout", "debt_equity", "pe", "roe"]),
120
+ "quality": ("High ROE, low debt", "ROE over 20%, operating margin over 18%, little debt, revenue still growing",
121
+ False, [("roe", ">", 20), ("operating_margin", ">", 18), ("debt_equity", "<", 0.5), ("revenue_growth", ">", 8)],
122
+ ("roe", True), ["roe", "operating_margin", "revenue_growth", "debt_equity", "pe"]),
123
+ "debt_free": ("Debt-free and profitable", "Debt under 0.1x equity and ROE over 15%",
124
+ False, [("debt_equity", "<", 0.1), ("roe", ">", 15), ("pe", ">", 0)],
125
+ ("roe", True), ["debt_equity", "roe", "pe", "operating_margin", "dividend_yield"]),
126
+ # Relative, not absolute: a P/E of 40 is cheap for one sector and dear for
127
+ # another, so each stock is compared with the median of its own sector
128
+ # among the same largest-200 universe (see _sector_relative).
129
+ "expensive_sector": ("P/E far above sector", "P/E over 1.5x the sector median while earnings growth trails the sector",
130
+ False, [("pe_vs_sector", ">", 1.5), ("growth_short", "is", True)],
131
+ ("pe_vs_sector", True), ["sector", "pe", "sector_pe", "pe_vs_sector", "pb_vs_sector", "earnings_growth", "peg_calc"]),
132
+ "near_low": ("Quality near 52-week low", "Profitable companies trading close to their yearly low",
133
+ False, [("from_low", "<", 10), ("roe", ">", 12), ("pe", ">", 0)],
134
+ ("from_low", False), ["from_low", "from_high", "pe", "roe", "debt_equity"]),
135
+ }
136
+
137
+ InfoFetch = Callable[[str], dict]
138
+
139
+
140
+ def metrics(info: dict) -> dict:
141
+ out = {}
142
+ for field, (key, fn) in FIELDS.items():
143
+ v = _f(info.get(key))
144
+ out[field] = fn(v) if v is not None else None
145
+ price, hi, lo = (_f(info.get(k)) for k in ("currentPrice", "fiftyTwoWeekHigh", "fiftyTwoWeekLow"))
146
+ out["from_high"] = (price / hi - 1) * 100 if price and hi else None
147
+ out["from_low"] = (price / lo - 1) * 100 if price and lo else None
148
+ pe, cap, fcf = out["pe"], _f(info.get("marketCap")), _f(info.get("freeCashflow"))
149
+ out["earnings_yield"] = 100 / pe if pe and pe > 0 else None
150
+ out["fcf_yield"] = fcf / cap * 100 if fcf is not None and cap else None
151
+ fwd, trail = _f(info.get("forwardEps")), _f(info.get("trailingEps"))
152
+ out["forward_eps_growth"] = (fwd / trail - 1) * 100 if fwd is not None and trail and trail > 0 else None
153
+ target = _f(info.get("targetMeanPrice"))
154
+ out["target_upside"] = (target / price - 1) * 100 if target and price else None
155
+ books, quote = info.get("financialCurrency"), info.get("currency")
156
+ if books and quote and books != quote:
157
+ for field in MIXED_CURRENCY:
158
+ out[field] = None
159
+ return out
160
+
161
+
162
+ def _passes(m: dict, rules: list) -> bool:
163
+ for field, op, value in rules:
164
+ v = m.get(field)
165
+ if v is None:
166
+ return False
167
+ if op == ">" and not v > value:
168
+ return False
169
+ if op == "<" and not v < value:
170
+ return False
171
+ if op == "between" and not value[0] < v < value[1]:
172
+ return False
173
+ if op == "is" and v is not value:
174
+ return False
175
+ return True
176
+
177
+
178
+ def list_presets() -> list[dict]:
179
+ return [{"key": k, "title": p[0], "description": p[1], "large_only": p[2],
180
+ "rules": [_rule_text(r) for r in p[3]]} for k, p in PRESETS.items()]
181
+
182
+
183
+ def _rule_text(rule) -> str:
184
+ field, op, value = rule
185
+ label = LABELS.get(field, field)
186
+ if op == "between":
187
+ return f"{label} {value[0]:g}–{value[1]:g}"
188
+ if op == "is":
189
+ return label[:1].upper() + label[1:]
190
+ return f"{label} {op} {value:g}"
191
+
192
+
193
+ def _sector_relative(ms: list[dict]) -> None:
194
+ """Adds sector_pe, pe_vs_sector, pb_vs_sector, peg_calc and growth_short
195
+ in place. Medians use profitable companies only (P/E > 0) and need
196
+ SECTOR_MIN_PEERS of them; otherwise the relative fields stay None."""
197
+ by_sector: dict[str, list[dict]] = {}
198
+ for m in ms:
199
+ if m.get("sector") and (m.get("pe") or 0) > 0:
200
+ by_sector.setdefault(m["sector"], []).append(m)
201
+ for m in ms:
202
+ pe, pb, growth = m.get("pe"), m.get("pb"), m.get("earnings_growth")
203
+ peers = by_sector.get(m.get("sector") or "", [])
204
+ m["sector_pe"] = m["pe_vs_sector"] = m["pb_vs_sector"] = None
205
+ if len(peers) >= SECTOR_MIN_PEERS and pe and pe > 0:
206
+ sector_pe = _median([p["pe"] for p in peers])
207
+ sector_pb = _median([p["pb"] for p in peers if (p.get("pb") or 0) > 0])
208
+ m["sector_pe"] = sector_pe
209
+ m["pe_vs_sector"] = pe / sector_pe if sector_pe else None
210
+ m["pb_vs_sector"] = pb / sector_pb if pb and pb > 0 and sector_pb else None
211
+ m["peg_calc"] = pe / growth if pe and pe > 0 and growth and growth > 0 else None
212
+ # Growth that doesn't carry the multiple: shrinking earnings, or PEG > 2.
213
+ m["growth_short"] = (growth is not None and growth <= 0) or (m["peg_calc"] is not None and m["peg_calc"] > 2)
214
+
215
+
216
+ def _measure(market: str, large_only: bool, info_fetch: Optional[InfoFetch], rows: Optional[list[dict]]) -> list[tuple]:
217
+ """(listing row, info, metrics) for the largest stocks of a market, with
218
+ the sector-relative fields filled in."""
219
+ from .analysis_run import yahoo_symbol
220
+ from .compare import _yahoo_info
221
+
222
+ info_fetch = info_fetch or _yahoo_info
223
+ if rows is None:
224
+ from ..data.listings import get_store
225
+
226
+ rows = get_store().rows(market)
227
+ stocks = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
228
+ key=lambda r: -r["market_cap"])[:UNIVERSE]
229
+ if large_only:
230
+ stocks = stocks[:LARGE]
231
+ with ThreadPoolExecutor(max_workers=8) as pool:
232
+ infos = list(pool.map(lambda r: info_fetch(yahoo_symbol(r["symbol"], market)), stocks))
233
+
234
+ measured = [(r, info, metrics(info)) for r, info in zip(stocks, infos) if info]
235
+ for r, info, m in measured:
236
+ m["sector"] = info.get("sector") or r.get("sector")
237
+ m["market_cap"] = r["market_cap"]
238
+ m["price"] = r.get("price")
239
+ _sector_relative([m for _, _, m in measured])
240
+ return measured
241
+
242
+
243
+ def _hits(measured: list[tuple], market: str, passes: Callable[[dict], bool], columns: list[str],
244
+ sort_field: str, desc_sort: bool) -> list[dict]:
245
+ hits, seen = [], set()
246
+ for r, info, m in measured:
247
+ company = (info.get("longName") or info.get("shortName") or r["symbol"]).lower()
248
+ if company in seen: # second share class of the same company (BRK-A / BRK-B)
249
+ continue
250
+ if passes(m):
251
+ seen.add(company)
252
+ hits.append({"symbol": r["symbol"], "name": r.get("name"), "market": market,
253
+ "market_cap": r["market_cap"], "price": r.get("price"), "currency": r.get("currency"),
254
+ "change_percent": r.get("change_percent"), "sector": m["sector"],
255
+ **{c: m.get(c) for c in columns}})
256
+ hits.sort(key=lambda h: (h.get(sort_field) is None, -(h.get(sort_field) or 0) if desc_sort else (h.get(sort_field) or 0)))
257
+ return hits
258
+
259
+
260
+ def run(key: str, market: str, info_fetch: Optional[InfoFetch] = None, rows: Optional[list[dict]] = None) -> dict:
261
+ if key not in PRESETS:
262
+ raise ValueError(f"Unknown screen '{key}'")
263
+ title, desc, large_only, rules, (sort_field, desc_sort), columns = PRESETS[key]
264
+ measured = _measure(market, large_only, info_fetch, rows)
265
+ hits = _hits(measured, market, lambda m: _passes(m, rules), columns, sort_field, desc_sort)
266
+ return {
267
+ "key": key, "title": title, "description": desc, "market": market,
268
+ "rules": [_rule_text(r) for r in rules],
269
+ "columns": [{"key": c, "label": LABELS.get(c, c)} for c in columns],
270
+ "universe": f"Largest {LARGE if large_only else UNIVERSE} stocks by market cap",
271
+ "scanned": len(measured),
272
+ "results": hits,
273
+ }
274
+
275
+
276
+ # ---- custom screens ---------------------------------------------------------
277
+
278
+ def catalogue() -> list[dict]:
279
+ return [{"group": g, "metrics": [{"key": k, "label": LABELS[k], "hint": h} for k, h in items]} for g, items in CATALOGUE]
280
+
281
+
282
+ class RuleError(ValueError):
283
+ """A custom-screen rule the user has to fix; the message is shown as is."""
284
+
285
+
286
+ def validate(rules: list[dict]) -> list[dict]:
287
+ """Clean rules: known field and op, numeric values, `between` ordered.
288
+ Raises RuleError with a message fit for the user."""
289
+ if not rules:
290
+ raise RuleError("Add at least one rule")
291
+ if len(rules) > MAX_RULES:
292
+ raise RuleError(f"At most {MAX_RULES} rules")
293
+ out = []
294
+ for r in rules:
295
+ field, op = r.get("field"), r.get("op")
296
+ if field not in CUSTOM_FIELDS:
297
+ raise RuleError(f"Unknown metric '{field}'")
298
+ if op not in CUSTOM_OPS:
299
+ raise RuleError(f"Unknown operator '{op}'")
300
+ value = _f(r.get("value"))
301
+ if value is None and op not in ("below_sector", "above_sector"):
302
+ raise RuleError(f"{LABELS[field]}: enter a number")
303
+ rule = {"field": field, "op": op, "value": value}
304
+ if op == "between":
305
+ hi = _f(r.get("value2"))
306
+ if hi is None:
307
+ raise RuleError(f"{LABELS[field]}: enter both ends of the range")
308
+ rule["value"], rule["value2"] = min(value, hi), max(value, hi)
309
+ if op in ("top", "bottom") and not 0 < value <= 100:
310
+ raise RuleError(f"{LABELS[field]}: top / bottom takes a percent from 1 to 100")
311
+ if op in ("below_sector", "above_sector"):
312
+ rule["value"] = value if value and value > 0 else 1.0
313
+ out.append(rule)
314
+ return out
315
+
316
+
317
+ def custom_rule_text(rule: dict) -> str:
318
+ label, op, v = LABELS[rule["field"]], rule["op"], rule["value"]
319
+ if op == "between":
320
+ return f"{label} {v:g}–{rule['value2']:g}"
321
+ if op in ("top", "bottom"):
322
+ return f"{label} in the {op} {v:g}%"
323
+ if op in ("below_sector", "above_sector"):
324
+ side = "below" if op == "below_sector" else "above"
325
+ return f"{label} {side} sector median" + (f" × {v:g}" if v != 1 else "")
326
+ return f"{label} {op} {v:g}"
327
+
328
+
329
+ def _percentile_cut(values: list[float], pct: float, top: bool) -> float:
330
+ """Value a stock must reach to be in the top (or bottom) `pct` percent."""
331
+ ordered = sorted(values, reverse=top)
332
+ n = max(1, round(len(ordered) * pct / 100))
333
+ return ordered[min(n, len(ordered)) - 1]
334
+
335
+
336
+ def _sector_medians(ms: list[dict], field: str) -> dict[str, float]:
337
+ by: dict[str, list[float]] = {}
338
+ for m in ms:
339
+ v = m.get(field)
340
+ if m.get("sector") and v is not None and (v > 0 or field not in POSITIVE_ONLY):
341
+ by.setdefault(m["sector"], []).append(v)
342
+ return {s: _median(vs) for s, vs in by.items() if len(vs) >= SECTOR_MIN_PEERS}
343
+
344
+
345
+ def run_custom(market: str, rules: list[dict], sectors: Optional[list[str]] = None, sort: Optional[str] = None,
346
+ descending: bool = True, columns: Optional[list[str]] = None, large_only: bool = False,
347
+ info_fetch: Optional[InfoFetch] = None, rows: Optional[list[dict]] = None) -> dict:
348
+ """Run user rules (see validate) over the same universe as the presets.
349
+ Top / bottom % and sector medians are taken over the stocks left after
350
+ the sector filter, so "top 10% ROE in Banks" ranks banks only."""
351
+ rules = validate(rules)
352
+ measured = _measure(market, large_only, info_fetch, rows)
353
+ all_sectors = sorted({m["sector"] for _, _, m in measured if m.get("sector")})
354
+ wanted = {s.lower() for s in sectors or []}
355
+ if wanted:
356
+ measured = [t for t in measured if (t[2].get("sector") or "").lower() in wanted]
357
+ ms = [m for _, _, m in measured]
358
+
359
+ checks: list[Callable[[dict], bool]] = []
360
+ for rule in rules:
361
+ field, op, v = rule["field"], rule["op"], rule["value"]
362
+ if op in ("top", "bottom"):
363
+ values = [m[field] for m in ms if m.get(field) is not None]
364
+ cut = _percentile_cut(values, v, op == "top") if values else None
365
+ checks.append(lambda m, f=field, c=cut, top=op == "top": c is not None and m.get(f) is not None
366
+ and (m[f] >= c if top else m[f] <= c))
367
+ elif op in ("below_sector", "above_sector"):
368
+ med = _sector_medians(ms, field)
369
+
370
+ def sector_check(m, f=field, med=med, k=v, below=op == "below_sector"):
371
+ x, base = m.get(f), med.get(m.get("sector") or "")
372
+ if x is None or base is None or (f in POSITIVE_ONLY and x <= 0):
373
+ return False
374
+ return x < base * k if below else x > base * k
375
+ checks.append(sector_check)
376
+ elif op == "between":
377
+ checks.append(lambda m, f=field, lo=v, hi=rule["value2"]: m.get(f) is not None and lo <= m[f] <= hi)
378
+ elif op == ">":
379
+ checks.append(lambda m, f=field, x=v: m.get(f) is not None and m[f] > x)
380
+ else:
381
+ checks.append(lambda m, f=field, x=v: m.get(f) is not None and m[f] < x)
382
+
383
+ used = list(dict.fromkeys([r["field"] for r in rules] + [c for c in columns or [] if c in CUSTOM_FIELDS]))
384
+ used = [c for c in used if c not in ("market_cap", "price")] # always shown
385
+ sort = sort if sort in CUSTOM_FIELDS else rules[0]["field"]
386
+ hits = _hits(measured, market, lambda m: all(c(m) for c in checks), used, sort, descending)
387
+ text = [custom_rule_text(r) for r in rules]
388
+ if wanted:
389
+ text.insert(0, "Sector: " + ", ".join(s for s in all_sectors if s.lower() in wanted))
390
+ return {
391
+ "key": "custom", "title": "Your screen", "description": "Your own rules", "market": market,
392
+ "rules": text,
393
+ "columns": [{"key": c, "label": LABELS.get(c, c)} for c in used],
394
+ "universe": f"Largest {LARGE if large_only else UNIVERSE} stocks by market cap" + (" in the chosen sectors" if wanted else ""),
395
+ "scanned": len(measured),
396
+ "sectors": all_sectors,
397
+ "results": hits,
398
+ }
@@ -0,0 +1,173 @@
1
+ """AI summary of Management's Discussion and Analysis (MD&A) from a US
2
+ company's latest 10-K or 10-Q on SEC EDGAR (free, no key).
3
+
4
+ SEC's fair-access policy requires a User-Agent naming the caller with a
5
+ contact email, so the feature is off until SEC_USER_AGENT is set, e.g.
6
+ "MarketMyna you@example.com". Stands in for OpenBB's
7
+ equity.fundamental.management_discussion_analysis (Apache 2.0).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import re
15
+ import threading
16
+ import time
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
21
+ SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik:010d}.json"
22
+ DOC_URL = "https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/{doc}"
23
+ MAX_CHARS = 24000
24
+ CACHE_SECONDS = 24 * 3600
25
+
26
+ _cache: dict[str, tuple[float, object]] = {}
27
+ _lock = threading.Lock()
28
+
29
+
30
+ class SecNotConfigured(RuntimeError):
31
+ pass
32
+
33
+
34
+ def user_agent() -> str:
35
+ ua = os.environ.get("SEC_USER_AGENT", "").strip()
36
+ if "@" not in ua:
37
+ raise SecNotConfigured("Set SEC_USER_AGENT (a name and contact email, e.g. 'MarketMyna you@example.com') "
38
+ "to read SEC filings - SEC requires it.")
39
+ return ua
40
+
41
+
42
+ def _get(url: str):
43
+ import requests
44
+
45
+ resp = requests.get(url, headers={"User-Agent": user_agent(), "Accept-Encoding": "gzip, deflate"}, timeout=30)
46
+ resp.raise_for_status()
47
+ return resp
48
+
49
+
50
+ def _cached(key: str, fn):
51
+ with _lock:
52
+ hit = _cache.get(key)
53
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
54
+ return hit[1]
55
+ value = fn()
56
+ with _lock:
57
+ _cache[key] = (time.time(), value)
58
+ return value
59
+
60
+
61
+ def cik_for(symbol: str) -> int:
62
+ table = _cached("tickers", lambda: {v["ticker"].upper(): int(v["cik_str"]) for v in _get(TICKERS_URL).json().values()})
63
+ cik = table.get(symbol.upper().replace(".", "-"))
64
+ if cik is None:
65
+ raise ValueError(f"{symbol} is not an SEC filer")
66
+ return cik
67
+
68
+
69
+ def latest_filing(cik: int) -> dict:
70
+ recent = _get(SUBMISSIONS_URL.format(cik=cik)).json()["filings"]["recent"]
71
+ for i, form in enumerate(recent["form"]):
72
+ if form in ("10-K", "10-Q"):
73
+ return {"form": form, "filed": recent["filingDate"][i], "period": recent["reportDate"][i],
74
+ "accession": recent["accessionNumber"][i], "document": recent["primaryDocument"][i]}
75
+ raise ValueError("No 10-K or 10-Q on file")
76
+
77
+
78
+ def html_to_text(html: str) -> str:
79
+ from bs4 import BeautifulSoup
80
+
81
+ soup = BeautifulSoup(html, "html.parser")
82
+ for tag in soup(["script", "style"]):
83
+ tag.decompose()
84
+ for hidden in soup.select('[style*="display:none"], [style*="display: none"]'):
85
+ hidden.decompose()
86
+ text = soup.get_text("\n")
87
+ text = re.sub(r"\xa0", " ", text)
88
+ text = re.sub(r"[ \t]+", " ", text)
89
+ return re.sub(r"\n\s*\n+", "\n\n", text).strip()
90
+
91
+
92
+ _START = {"10-K": r"item\s*7\.?\s*[-–—:]?\s*management[’'`s]*\s+discussion",
93
+ "10-Q": r"item\s*2\.?\s*[-–—:]?\s*management[’'`s]*\s+discussion"}
94
+ _END = {"10-K": r"item\s*7a\.?|item\s*8\.?\s*[-–—:]?\s*financial\s+statements",
95
+ "10-Q": r"item\s*3\.?\s*[-–—:]?\s*quantitative|item\s*4\.?\s*[-–—:]?\s*controls"}
96
+
97
+
98
+ def extract_mdna(text: str, form: str) -> str:
99
+ """The MD&A section. The heading also appears in the table of contents, so
100
+ take the candidate that yields the longest section."""
101
+ best = ""
102
+ for m in re.finditer(_START[form], text, flags=re.I):
103
+ end = re.search(_END[form], text[m.end():], flags=re.I)
104
+ section = text[m.start(): m.end() + end.start()] if end else text[m.start(): m.start() + MAX_CHARS * 2]
105
+ if len(section) > len(best):
106
+ best = section
107
+ if len(best) < 1500:
108
+ best = _fallback(text)
109
+ if len(best) < 1500:
110
+ raise ValueError("Could not find the MD&A section in the filing")
111
+ return best.strip()
112
+
113
+
114
+ def _fallback(text: str) -> str:
115
+ """Banks and some others open with MD&A without an 'Item' heading: take the
116
+ bare heading followed by the longest stretch before it repeats."""
117
+ hits = [m.start() for m in re.finditer(r"management[’'`s]*\s+discussion\s+and\s+analysis", text, flags=re.I)]
118
+ best = ""
119
+ for i, start in enumerate(hits):
120
+ stop = hits[i + 1] if i + 1 < len(hits) else len(text)
121
+ chunk = text[start:min(stop, start + MAX_CHARS * 2)]
122
+ if len(chunk) > len(best):
123
+ best = chunk
124
+ return best
125
+
126
+
127
+ def mdna(symbol: str) -> dict:
128
+ cik = cik_for(symbol)
129
+ filing = latest_filing(cik)
130
+
131
+ def load():
132
+ acc = filing["accession"].replace("-", "")
133
+ html = _get(DOC_URL.format(cik=cik, acc=acc, doc=filing["document"])).text
134
+ return extract_mdna(html_to_text(html), filing["form"])
135
+
136
+ section = _cached(f"mdna:{filing['accession']}", load)
137
+ acc = filing["accession"].replace("-", "")
138
+ return {**filing, "cik": cik, "url": DOC_URL.format(cik=cik, acc=acc, doc=filing["document"]),
139
+ "chars": len(section), "text": section}
140
+
141
+
142
+ SYSTEM = (
143
+ "You are an equity analyst summarising the Management's Discussion and Analysis section of an SEC filing "
144
+ "for a retail investor. Use ONLY the text given. Write Markdown with these sections: '### What happened' "
145
+ "(results vs the prior period, with the key numbers), '### Why' (the drivers management gives), "
146
+ "'### Outlook' (guidance, plans, capital spending, buybacks - say so if none is given), "
147
+ "'### Risks management flagged', '### Watch next' (2-3 bullets). Keep each section to 2-4 sentences or "
148
+ "bullets. Quote numbers exactly. No price targets, no buy/sell advice, no disclaimers."
149
+ )
150
+
151
+
152
+ def ai_summary(symbol: str, config) -> dict:
153
+ from ..llm.backend_factory import create_generation_backend
154
+ from ..llm.protocol import GenerationConfig
155
+
156
+ doc = mdna(symbol)
157
+ key = f"ai:{doc['accession']}"
158
+ with _lock:
159
+ hit = _cache.get(key)
160
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
161
+ summary = hit[1]
162
+ else:
163
+ text = doc["text"][:MAX_CHARS]
164
+ prompt = (f"Company: {symbol}. Filing: {doc['form']} for the period ending {doc['period']}, filed {doc['filed']}."
165
+ f"{' (Section truncated.)' if doc['chars'] > MAX_CHARS else ''}\n\n{text}")
166
+ result = create_generation_backend(config).generate(
167
+ prompt, GenerationConfig(max_tokens=1100, timeout=110), system_prompt=SYSTEM)
168
+ summary = (result.text or "").strip()
169
+ if not summary:
170
+ raise RuntimeError("The AI model returned an empty summary")
171
+ with _lock:
172
+ _cache[key] = (time.time(), summary)
173
+ return {k: doc[k] for k in ("form", "filed", "period", "url", "chars")} | {"symbol": symbol, "summary": summary}