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,305 @@
1
+ """Earnings preview and review for one stock (the equity-research "earnings
2
+ preview" / "earnings reviewer" workflow, on free data).
3
+
4
+ Before the report:
5
+ - next date and time, consensus EPS / revenue and how estimates moved
6
+ (services/estimates.py)
7
+ - beat / miss record and how the stock really moved on past report days:
8
+ last close before the announcement to the first close after it, so a
9
+ pre-open and an after-close report are both measured on the right day
10
+ - the move the options market prices in (at-the-money straddle on the first
11
+ expiry after the date), where an option chain is available
12
+ - what to watch: the metrics that move this kind of business
13
+
14
+ After the report: actual vs estimated EPS, the surprise and the price
15
+ reaction, for the "results are out" view and message.
16
+
17
+ Every figure carries its source (Yahoo Finance; options from the exchange or
18
+ Yahoo). Nothing here is a recommendation.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import threading
25
+ import time
26
+ from datetime import date, datetime, timedelta, timezone
27
+ from typing import Callable, Optional
28
+ from zoneinfo import ZoneInfo
29
+
30
+ import pandas as pd
31
+
32
+ from ..core.numbers import finite
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ CACHE_SECONDS = 6 * 3600
37
+ RECENT_DAYS = 4 # a report this many days old still counts as "just out"
38
+ # An estimate or actual this many times off the company's usual quarterly EPS
39
+ # means Yahoo mixed up periods (annual vs quarterly, a split), not a real miss:
40
+ # shown, but flagged and left out of the averages.
41
+ OFF_SCALE = 2.5
42
+ CLOSE = {"india": (15, 30, "Asia/Kolkata"), "us": (16, 0, "America/New_York"),
43
+ "hong_kong": (16, 0, "Asia/Hong_Kong"), "china_a": (15, 0, "Asia/Shanghai")}
44
+
45
+ # What moves each kind of business on results day (beyond revenue, EPS,
46
+ # margins and guidance, which matter everywhere).
47
+ WATCH = {
48
+ "Technology": ["Revenue growth in constant currency and deal wins / bookings",
49
+ "Operating margin and wage or cloud-cost pressure",
50
+ "Full-year revenue guidance (raised, held or cut)",
51
+ "Headcount, attrition and utilisation (IT services)"],
52
+ "Financial Services": ["Net interest margin (NIM)", "Asset quality: gross / net NPA and slippages",
53
+ "Loan and deposit growth, CASA ratio", "Credit cost and provisions",
54
+ "Fee income and cost-to-income ratio"],
55
+ "Consumer Defensive": ["Volume growth (not just price-led growth)", "Rural vs urban demand",
56
+ "Gross margin vs input costs (palm oil, crude, milk...)", "Ad spend as a share of sales"],
57
+ "Consumer Cyclical": ["Volumes and average selling price", "Same-store sales growth (retail)",
58
+ "EBITDA margin vs raw-material costs", "Inventory and dealer stock levels"],
59
+ "Healthcare": ["US generics pricing and new launches (pharma)", "Specialty / branded mix",
60
+ "R&D spend as a share of sales", "Regulatory status: FDA observations or warning letters"],
61
+ "Energy": ["Refining margins (GRM) and crude realisations", "Production volumes",
62
+ "Marketing margins on fuel", "Capex plans and debt"],
63
+ "Industrials": ["Order inflow and order book (book-to-bill)", "Execution: revenue from the order book",
64
+ "Margins on fixed-price contracts", "Working capital and receivables"],
65
+ "Basic Materials": ["Realisation per tonne vs cost per tonne", "Sales volume and capacity use",
66
+ "Energy and raw-material costs", "Capex and net debt"],
67
+ "Communication Services": ["Average revenue per user (ARPU)", "Subscriber additions and churn",
68
+ "Capex intensity", "Advertising revenue (media)"],
69
+ "Utilities": ["Plant load factor / generation", "Regulated equity and tariff orders",
70
+ "Receivables from state utilities", "Renewable capacity additions"],
71
+ "Real Estate": ["Pre-sales / bookings and collections", "New launches", "Net debt", "Rental income (offices, malls)"],
72
+ }
73
+ COMMON = ["Revenue vs consensus", "EPS vs consensus", "Operating / EBITDA margin vs last year",
74
+ "Guidance or management commentary for the next quarter"]
75
+
76
+ _cache: dict[str, tuple[float, dict]] = {}
77
+ _lock = threading.Lock()
78
+
79
+ Fetch = Callable[[str], dict]
80
+
81
+
82
+ def _yahoo(yf_symbol: str) -> dict:
83
+ """Raw inputs: earnings dates table, 2 years of daily closes, sector."""
84
+ import yfinance as yf
85
+
86
+ t = yf.Ticker(yf_symbol)
87
+ out: dict = {}
88
+ try:
89
+ out["dates"] = t.get_earnings_dates(limit=16)
90
+ except Exception as exc:
91
+ logger.info("Earnings dates for %s unavailable: %s", yf_symbol, exc)
92
+ out["dates"] = None
93
+ try:
94
+ hist = t.history(period="3y", interval="1d", auto_adjust=True)
95
+ out["closes"] = hist["Close"] if not hist.empty else None
96
+ except Exception:
97
+ out["closes"] = None
98
+ try:
99
+ info = t.info or {}
100
+ out["sector"], out["industry"] = info.get("sector"), info.get("industry")
101
+ out["currency"] = info.get("currency")
102
+ except Exception:
103
+ out["sector"] = out["industry"] = out["currency"] = None
104
+ return out
105
+
106
+
107
+ def events(dates: Optional[pd.DataFrame]) -> list[dict]:
108
+ """Yahoo's earnings table -> [{at (UTC datetime), estimate, actual, surprise}], newest first."""
109
+ if dates is None or dates.empty:
110
+ return []
111
+ out = []
112
+ for at, row in dates.iterrows():
113
+ ts = pd.Timestamp(at)
114
+ ts = ts.tz_localize("America/New_York") if ts.tzinfo is None else ts
115
+ out.append({"at": ts.tz_convert("UTC").to_pydatetime(),
116
+ "estimate": finite(row.get("EPS Estimate")), "actual": finite(row.get("Reported EPS")),
117
+ "surprise": finite(row.get("Surprise(%)"))})
118
+ out.sort(key=lambda e: e["at"], reverse=True)
119
+ return out
120
+
121
+
122
+ def _close_times(closes: pd.Series, market: str) -> pd.Series:
123
+ """Daily closes re-indexed by the moment each session closed (UTC)."""
124
+ hour, minute, tz = CLOSE.get(market, CLOSE["us"])
125
+ days = pd.DatetimeIndex(closes.index)
126
+ days = days.tz_localize(None) if days.tz is not None else days
127
+ stamps = [datetime(d.year, d.month, d.day, hour, minute, tzinfo=ZoneInfo(tz)).astimezone(timezone.utc) for d in days]
128
+ return pd.Series(closes.values, index=pd.DatetimeIndex(stamps)).dropna()
129
+
130
+
131
+ def reaction(at: datetime, closes_utc: pd.Series) -> Optional[dict]:
132
+ """Move from the last close before the announcement to the first close after it."""
133
+ before = closes_utc[closes_utc.index <= at]
134
+ after = closes_utc[closes_utc.index > at]
135
+ if before.empty or after.empty or (after.index[0] - at) > timedelta(days=6):
136
+ return None
137
+ return {"move": float(after.iloc[0] / before.iloc[-1] - 1) * 100,
138
+ "from": str(before.index[-1].date()), "to": str(after.index[0].date())}
139
+
140
+
141
+ def mark_unusual(evs: list[dict]) -> None:
142
+ """Flag events whose estimate or actual is off the scale of the others."""
143
+ sizes = sorted(abs(e["estimate"]) for e in evs if e.get("estimate"))
144
+ usual = sizes[len(sizes) // 2] if sizes else None
145
+ for e in evs:
146
+ e["unusual"] = False
147
+ if not usual or usual < 0.05: # tiny or loss-making EPS: ratios mean nothing
148
+ continue
149
+ for v in (e.get("estimate"), e.get("actual")):
150
+ if v and (abs(v) > usual * OFF_SCALE or abs(v) < usual / OFF_SCALE):
151
+ e["unusual"] = True
152
+
153
+
154
+ def unusual(e: dict) -> bool:
155
+ return bool(e.get("unusual"))
156
+
157
+
158
+ def history_stats(past: list[dict]) -> Optional[dict]:
159
+ moves = [e["reaction"]["move"] for e in past if e.get("reaction")]
160
+ if not moves:
161
+ return None
162
+ beats = [e for e in past if e.get("surprise") is not None and not unusual(e)]
163
+ on_beat = [e["reaction"]["move"] for e in beats if e["surprise"] > 0 and e.get("reaction")]
164
+ on_miss = [e["reaction"]["move"] for e in beats if e["surprise"] < 0 and e.get("reaction")]
165
+ avg = lambda v: sum(v) / len(v) if v else None # noqa: E731
166
+ return {"reports": len(moves), "avg_abs_move": avg([abs(m) for m in moves]),
167
+ "biggest_up": max(moves), "biggest_down": min(moves), "up_days": sum(1 for m in moves if m > 0),
168
+ "beats": sum(1 for e in beats if e["surprise"] > 0), "misses": sum(1 for e in beats if e["surprise"] < 0),
169
+ "avg_move_on_beat": avg(on_beat), "avg_move_on_miss": avg(on_miss)}
170
+
171
+
172
+ def implied_move(symbol: str, market: str, report_day: datetime) -> Optional[dict]:
173
+ """At-the-money straddle on the first expiry on / after the report, as a % of spot."""
174
+ from ..trading import options
175
+
176
+ try:
177
+ exps = options.expiries(symbol, market)
178
+ except Exception as exc:
179
+ logger.info("No option expiries for %s: %s", symbol, exc)
180
+ return None
181
+ day = report_day.date()
182
+ after = sorted((options._parse_expiry(e), e) for e in exps if options._parse_expiry(e) >= day)
183
+ if not after or (after[0][0] - day).days > 35:
184
+ return None
185
+ try:
186
+ ch = options.chain(symbol, market, after[0][1])
187
+ except Exception as exc:
188
+ logger.info("Option chain for %s unavailable: %s", symbol, exc)
189
+ return None
190
+ spot, rows = (ch.get("summary") or {}).get("spot") or 0, ch.get("rows") or []
191
+ if not spot or not rows:
192
+ return None
193
+ atm = min(rows, key=lambda r: abs(r["strike"] - spot))
194
+
195
+ def mid(leg: Optional[dict]) -> Optional[float]:
196
+ if not leg:
197
+ return None
198
+ bid, ask = finite(leg.get("bid")), finite(leg.get("ask"))
199
+ if bid and ask and ask >= bid:
200
+ return (bid + ask) / 2
201
+ return finite(leg.get("ltp"))
202
+
203
+ call, put = mid(atm.get("call")), mid(atm.get("put"))
204
+ if not call or not put:
205
+ return None
206
+ return {"move": (call + put) / spot * 100, "expiry": after[0][1], "strike": atm["strike"]}
207
+
208
+
209
+ def watch_for(sector: Optional[str]) -> list[str]:
210
+ return COMMON + WATCH.get(sector or "", [])
211
+
212
+
213
+ def _estimates(yf_symbol: str) -> Optional[dict]:
214
+ from . import estimates
215
+
216
+ return estimates.build(yf_symbol)
217
+
218
+
219
+ def build(symbol: str, market: str, fetch: Fetch = _yahoo, with_options: bool = True,
220
+ now: Optional[datetime] = None, use_cache: bool = True,
221
+ estimates_fetch: Callable[[str], Optional[dict]] = _estimates) -> dict:
222
+ from .analysis_run import yahoo_symbol
223
+
224
+ key = f"{symbol}:{market}"
225
+ if use_cache:
226
+ with _lock:
227
+ hit = _cache.get(key)
228
+ if hit and time.time() - hit[0] < CACHE_SECONDS:
229
+ return hit[1]
230
+ now = now or datetime.now(timezone.utc)
231
+ raw = fetch(yahoo_symbol(symbol, market))
232
+ evs = events(raw.get("dates"))
233
+ mark_unusual(evs)
234
+ closes = raw.get("closes")
235
+ closes_utc = _close_times(closes, market) if closes is not None and len(closes) else None
236
+
237
+ upcoming = [e for e in evs if e["actual"] is None and e["at"] > now - timedelta(days=1)]
238
+ past = [e for e in evs if e["actual"] is not None]
239
+ for e in past:
240
+ e["reaction"] = reaction(e["at"], closes_utc) if closes_utc is not None else None
241
+ nxt = upcoming[-1] if upcoming else None # the soonest still-unreported date
242
+ next_out = _event_out(nxt, market) if nxt else None
243
+
244
+ latest = None
245
+ if past and (now - past[0]["at"]).days <= RECENT_DAYS:
246
+ latest = _event_out(past[0], market)
247
+ estimates = None
248
+ try:
249
+ estimates = estimates_fetch(yahoo_symbol(symbol, market))
250
+ except Exception as exc:
251
+ logger.info("Estimates for %s unavailable: %s", symbol, exc)
252
+
253
+ out = {
254
+ "symbol": symbol, "market": market, "currency": raw.get("currency"),
255
+ "sector": raw.get("sector"), "industry": raw.get("industry"),
256
+ "next": next_out,
257
+ "days_to_next": (date.fromisoformat(next_out["date"]) - now.date()).days if next_out else None,
258
+ "implied": implied_move(symbol, market, nxt["at"]) if nxt and with_options and market in ("us", "india") else None,
259
+ "latest": latest,
260
+ "history": [_event_out(e, market) for e in past[:12]],
261
+ "stats": history_stats(past[:12]),
262
+ "consensus": _consensus(estimates),
263
+ "watch": watch_for(raw.get("sector")),
264
+ "available": bool(evs),
265
+ "source": "Market data feed (earnings dates, EPS estimates and actuals, prices)",
266
+ }
267
+ if use_cache:
268
+ with _lock:
269
+ _cache[key] = (time.time(), out)
270
+ return out
271
+
272
+
273
+ def _event_out(e: dict, market: str) -> dict:
274
+ """`date` is the day on the exchange's own calendar (a 4 pm New York report is that day,
275
+ even though it is already the next morning in India)."""
276
+ tz = ZoneInfo(CLOSE.get(market, CLOSE["us"])[2])
277
+ return {"date": e["at"].astimezone(tz).strftime("%Y-%m-%d"),
278
+ "at": e["at"].astimezone(timezone.utc).isoformat(),
279
+ "estimate": e.get("estimate"), "actual": e.get("actual"), "surprise": e.get("surprise"),
280
+ "reaction": e.get("reaction"), "unusual": unusual(e)}
281
+
282
+
283
+ def _consensus(est: Optional[dict]) -> Optional[dict]:
284
+ """The current-quarter consensus and how it moved, from services/estimates."""
285
+ if not est or not est.get("periods"):
286
+ return None
287
+ q = next((p for p in est["periods"] if p["key"] == "0q"), est["periods"][0])
288
+ return {"period": q["label"], "eps": q["eps"], "revenue": q["revenue"], "trend": q["trend"],
289
+ "revisions": q["revisions"], "momentum": est.get("momentum")}
290
+
291
+
292
+ def results_line(symbol: str, data: dict) -> Optional[str]:
293
+ """One message line once results are out: actual vs estimate and the move."""
294
+ e = data.get("latest")
295
+ if not e or e.get("actual") is None:
296
+ return None
297
+ parts = [f"{symbol} results: EPS {e['actual']:.2f}"]
298
+ if e.get("estimate") is not None:
299
+ parts.append(f"vs {e['estimate']:.2f} expected")
300
+ if e.get("surprise") is not None:
301
+ parts.append(f"({'beat' if e['surprise'] > 0 else 'missed' if e['surprise'] < 0 else 'in line'} by {abs(e['surprise']):.1f}%)")
302
+ line = " ".join(parts)
303
+ if e.get("reaction"):
304
+ line += f"; stock {e['reaction']['move']:+.1f}% on the day"
305
+ return line