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,748 @@
1
+ """Event calendar for the symbols people follow: upcoming earnings,
2
+ ex-dividend and dividend payment dates across every watchlist and portfolio,
3
+ plus a once-a-day reminder digest sent from the alert monitor loop.
4
+
5
+ Dates come from Yahoo's per-ticker calendar (the same source as the stock
6
+ overview's "upcoming events"); for Indian stocks the exchange's own board
7
+ meetings and corporate actions (services/nse_events.py) replace Yahoo's
8
+ estimates and add splits, bonus issues and the like. Market holidays and
9
+ sessions come from services/market_calendar.py. Modeled on OpenBB's
10
+ equity.calendar (Apache 2.0).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import threading
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from datetime import date, datetime, timedelta
20
+ from typing import Callable, Optional
21
+ from zoneinfo import ZoneInfo
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ KINDS = {"earnings": "Earnings", "ex_dividend": "Ex-dividend", "dividend": "Dividend payment"} # Yahoo's fields
26
+ ORDER = ["earnings", "board_meeting", "corporate_action", "ex_dividend", "dividend"] # same-day order
27
+ DEFAULTS = {"notify": True, "days_before": 1, "notify_hour": 8, "timezone": "Asia/Kolkata"}
28
+ CACHE_SECONDS = 6 * 3600
29
+
30
+ # Every event type a user can turn on / off: key, group, name, what it actually is.
31
+ EVENT_TYPES = [
32
+ ("earnings", "stocks", "Earnings", "The company reports its quarterly results (revenue, profit, guidance). "
33
+ "The biggest single-day mover for a stock; the EPS estimate is what analysts expect."),
34
+ ("ex_dividend", "stocks", "Ex-dividend date", "Buy before this date to get the next dividend. "
35
+ "The share price usually drops by about the dividend amount on this day."),
36
+ ("dividend", "stocks", "Dividend payment", "The day the declared dividend is paid into shareholders' accounts."),
37
+ ("board_meeting", "stocks", "Board meeting", "Indian stocks: the board meets on fund raising, a split, a bonus "
38
+ "or other business, as filed with the exchange. Results meetings show as Earnings."),
39
+ ("corporate_action", "stocks", "Split, bonus, rights, buyback", "Indian stocks: the ex-date of a split, bonus "
40
+ "issue, rights issue or buyback. Buy before it to be entitled; the price adjusts on the day."),
41
+ ("fed", "us", "Fed rate decision", "The US Federal Reserve sets its policy interest rate, 8 times a year. "
42
+ "Moves global markets, the dollar and foreign money flows into India."),
43
+ ("us_cpi", "us", "US inflation (CPI)", "Monthly change in US consumer prices. The main number the Fed watches "
44
+ "before deciding rates."),
45
+ ("us_pce", "us", "US PCE inflation", "Another US inflation measure, from consumer spending data. "
46
+ "The Fed's own preferred gauge."),
47
+ ("us_jobs", "us", "US jobs report", "Jobs added and the unemployment rate, first Friday of the month. "
48
+ "Shows how strong the US economy is."),
49
+ ("us_gdp", "us", "US GDP", "How fast the US economy grew last quarter (first estimate, then revisions)."),
50
+ ("us_ppi", "us", "US producer prices (PPI)", "Prices businesses get at the wholesale level; an early hint of "
51
+ "where consumer inflation is heading."),
52
+ ("us_retail", "us", "US retail sales", "Monthly change in what US shoppers spend in stores and online."),
53
+ ("rbi", "india", "RBI rate decision", "The Reserve Bank of India's MPC sets the repo rate, about every "
54
+ "2 months. Drives loan and deposit rates and rate-sensitive stocks."),
55
+ ("in_cpi", "india", "India inflation (CPI)", "Monthly retail inflation from MoSPI, around the 12th. "
56
+ "RBI aims to keep it between 2% and 6%."),
57
+ ("in_gdp", "india", "India GDP", "How fast India's economy grew last quarter, from MoSPI at the end of "
58
+ "Feb / May / Aug / Nov."),
59
+ ("market_holiday", "markets", "Market holidays", "Days the India, US, Hong Kong or China exchanges are shut, "
60
+ "with the stocks of yours that won't trade and when the market reopens."),
61
+ ("short_day", "markets", "Short trading days", "US 1 pm closes (around Thanksgiving, Christmas, 4 July) and "
62
+ "Hong Kong half days on the eves of Christmas, New Year and Lunar New Year."),
63
+ ("special_session", "markets", "Special sessions", "Muhurat trading on Diwali and Union Budget day, when "
64
+ "Indian markets open even on a weekend."),
65
+ ("expiry", "markets", "F&O expiry", "Monthly expiry of Nifty, Sensex, Hang Seng and CSI 300 futures and "
66
+ "options, US options expiry and quarterly quad witching: heavy volume and late swings."),
67
+ ("index_rebalance", "markets", "Index reviews", "MSCI's quarterly index changes take effect: big foreign "
68
+ "fund flows into or out of the stocks added or removed."),
69
+ ("clock_change", "markets", "US clock changes", "US daylight saving starts or ends: the US session moves an "
70
+ "hour in India and the rest of Asia."),
71
+ ("custom", "mine", "My own events", "Dates you add yourself: SIP day, IPO listing, AGM, results you are "
72
+ "waiting for, a bill due - one-off or repeating every month / year."),
73
+ ]
74
+ REPEATS = ("none", "monthly", "yearly")
75
+ EVENT_KEYS = {k for k, *_ in EVENT_TYPES}
76
+ WORKERS = 8
77
+
78
+ Fetch = Callable[[str], dict]
79
+
80
+ _cache: dict[str, tuple[float, dict]] = {}
81
+ _cache_lock = threading.Lock()
82
+
83
+
84
+ def _as_date(value) -> Optional[date]:
85
+ if isinstance(value, (list, tuple)):
86
+ value = value[0] if value else None
87
+ if value is None:
88
+ return None
89
+ if isinstance(value, datetime):
90
+ return value.date()
91
+ if isinstance(value, date):
92
+ return value
93
+ try:
94
+ return date.fromisoformat(str(value)[:10])
95
+ except ValueError:
96
+ return None
97
+
98
+
99
+ def _yf_calendar(yf_symbol: str) -> dict:
100
+ """{kind: date, eps_estimate, revenue_estimate} from Yahoo, cached."""
101
+ now = time.time()
102
+ with _cache_lock:
103
+ hit = _cache.get(yf_symbol)
104
+ if hit and now - hit[0] < CACHE_SECONDS:
105
+ return hit[1]
106
+ import yfinance as yf
107
+
108
+ try:
109
+ cal = yf.Ticker(yf_symbol).calendar or {}
110
+ except Exception as exc:
111
+ logger.info("Calendar for %s unavailable: %s", yf_symbol, exc)
112
+ cal = {}
113
+ out = {
114
+ "earnings": _as_date(cal.get("Earnings Date")),
115
+ "ex_dividend": _as_date(cal.get("Ex-Dividend Date")),
116
+ "dividend": _as_date(cal.get("Dividend Date")),
117
+ "eps_estimate": cal.get("Earnings Average"),
118
+ "revenue_estimate": cal.get("Revenue Average"),
119
+ }
120
+ with _cache_lock:
121
+ _cache[yf_symbol] = (now, out)
122
+ return out
123
+
124
+
125
+ def list_sources(config=None, catalog: Optional[dict] = None) -> list[dict]:
126
+ """Every watchlist / portfolio a user can pick for events: [{id, kind, name, count}]."""
127
+ from .schedule_config import source_catalog
128
+
129
+ cat = catalog or source_catalog(config)
130
+ return ([{"id": f"p:{p['id']}", "kind": "portfolio", "name": p["name"], "count": len(p["symbols"])}
131
+ for p in cat["portfolios"]]
132
+ + [{"id": f"w:{w['id']}", "kind": "watchlist", "name": w["name"], "count": len(w["symbols"])}
133
+ for w in cat["watchlists"]])
134
+
135
+
136
+ def clean_symbols(items) -> list[dict]:
137
+ """[{symbol, market}] de-duplicated, upper-cased, crypto and blanks dropped."""
138
+ out, seen = [], set()
139
+ for i in items or []:
140
+ sym = str((i or {}).get("symbol", "")).strip().upper()[:40]
141
+ market = str((i or {}).get("market", "us")).strip().lower()[:32] or "us"
142
+ if sym and market != "crypto" and (sym, market) not in seen:
143
+ seen.add((sym, market))
144
+ out.append({"symbol": sym, "market": market})
145
+ return out
146
+
147
+
148
+ def followed_symbols(config=None, sources: Optional[list] = None, extra: Optional[list] = None,
149
+ catalog: Optional[dict] = None) -> dict[tuple[str, str], list[str]]:
150
+ """(symbol, market) -> where it is followed ("Portfolio: X", "Watchlist: Y").
151
+ `sources` limits it to some lists ("w:<id>" / "p:<id>"; None = all of them),
152
+ `extra` adds stocks followed just for events."""
153
+ from .schedule_config import source_catalog
154
+
155
+ cat = catalog or source_catalog(config)
156
+ want = None if sources is None else set(sources)
157
+ out: dict[tuple[str, str], list[str]] = {}
158
+ for p in cat["portfolios"]:
159
+ if want is None or f"p:{p.get('id')}" in want:
160
+ for s in p["symbols"]:
161
+ out.setdefault((s["symbol"], s["market"]), []).append(f"Portfolio: {p['name']}")
162
+ for w in cat["watchlists"]:
163
+ if want is None or f"w:{w['id']}" in want:
164
+ for s in w["symbols"]:
165
+ out.setdefault((s["symbol"], s["market"]), []).append(f"Watchlist: {w['name']}")
166
+ for s in clean_symbols(extra):
167
+ out.setdefault((s["symbol"], s["market"]), []).append("Added for events")
168
+ return {k: v for k, v in out.items() if k[1] != "crypto"}
169
+
170
+
171
+ def my_symbols(config, settings: Optional[dict] = None, catalog: Optional[dict] = None) -> dict[tuple[str, str], list[str]]:
172
+ """The user's "My events list": their chosen lists plus extra stocks."""
173
+ settings = settings if settings is not None else load_settings(config)
174
+ return followed_symbols(config, settings.get("sources"), settings.get("extra_symbols"), catalog)
175
+
176
+
177
+ def _nse() -> dict:
178
+ from .nse_events import india_events
179
+
180
+ return india_events()
181
+
182
+
183
+ def exchange_events(symbols: dict[tuple[str, str], list[str]], start: date, end: date, data: dict) -> list[dict]:
184
+ """Board meetings and corporate actions from the exchange for the
185
+ followed Indian stocks: results meetings come as "earnings"."""
186
+ india = {s for s, m in symbols if m == "india"}
187
+ out = []
188
+ for r in data.get("meetings") or []:
189
+ d = date.fromisoformat(r["date"])
190
+ if r["symbol"] not in india or not (start <= d <= end):
191
+ continue
192
+ results = "results" in r["topics"]
193
+ out.append({"kind": "earnings" if results else "board_meeting",
194
+ "label": "Earnings" if results else "Board meeting", "symbol": r["symbol"], "date": d,
195
+ "detail": ("Board meeting: " + ", ".join(r["topics"])) if r["topics"] else "Board meeting",
196
+ "confirmed": True})
197
+ for r in data.get("actions") or []:
198
+ d = date.fromisoformat(r["date"])
199
+ if r["symbol"] not in india or not (start <= d <= end):
200
+ continue
201
+ dividend = r["label"] == "Dividend"
202
+ out.append({"kind": "ex_dividend" if dividend else "corporate_action",
203
+ "label": "Ex-dividend" if dividend else r["label"], "symbol": r["symbol"], "date": d,
204
+ "detail": r["subject"], "confirmed": True})
205
+ return out
206
+
207
+
208
+ def collect(symbols: dict[tuple[str, str], list[str]], start: date, days: int, fetch: Fetch = _yf_calendar,
209
+ exchange: Optional[Callable[[], dict]] = None) -> list[dict]:
210
+ """Events dated start..start+days for the given symbols, soonest first.
211
+ `exchange` returns NSE's meetings / actions (nse_events.india_events);
212
+ by default it is used together with the live Yahoo fetch only."""
213
+ from .analysis_run import yahoo_symbol
214
+
215
+ if exchange is None and fetch is _yf_calendar:
216
+ exchange = _nse
217
+
218
+ end = start + timedelta(days=days)
219
+ keys = list(symbols)
220
+
221
+ def one(key):
222
+ try:
223
+ return key, fetch(yahoo_symbol(*key))
224
+ except Exception as exc:
225
+ logger.info("Calendar for %s failed: %s", key[0], exc)
226
+ return key, {}
227
+
228
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
229
+ results = list(pool.map(one, keys))
230
+
231
+ official = []
232
+ if exchange and any(m == "india" for _, m in keys):
233
+ try:
234
+ official = exchange_events(symbols, start, end, exchange())
235
+ except Exception as exc:
236
+ logger.info("Exchange events unavailable: %s", exc)
237
+ # the exchange's date wins over Yahoo's for the same stock and kind
238
+ replaced = {(o["symbol"], o["kind"]) for o in official if o["kind"] in KINDS}
239
+ estimates = {symbol: cal for (symbol, market), cal in results if market == "india"}
240
+
241
+ events = []
242
+ for (symbol, market), cal in results:
243
+ for kind in KINDS:
244
+ when = cal.get(kind)
245
+ if when and start <= when <= end and not (market == "india" and (symbol, kind) in replaced):
246
+ event = {"symbol": symbol, "market": market, "kind": kind, "label": KINDS[kind],
247
+ "date": when.isoformat(), "days_away": (when - start).days,
248
+ "followed_in": symbols[(symbol, market)]}
249
+ if kind == "earnings":
250
+ event["eps_estimate"] = cal.get("eps_estimate")
251
+ event["revenue_estimate"] = cal.get("revenue_estimate")
252
+ events.append(event)
253
+ for o in official:
254
+ when = o.pop("date")
255
+ event = {**o, "market": "india", "date": when.isoformat(), "days_away": (when - start).days,
256
+ "followed_in": symbols[(o["symbol"], "india")]}
257
+ if o["kind"] == "earnings":
258
+ cal = estimates.get(o["symbol"]) or {}
259
+ event["eps_estimate"] = cal.get("eps_estimate")
260
+ event["revenue_estimate"] = cal.get("revenue_estimate")
261
+ events.append(event)
262
+ events.sort(key=lambda e: (e["date"], ORDER.index(e["kind"]), e["symbol"]))
263
+ return events
264
+
265
+
266
+ # ------------------------------------------------------------------ settings
267
+
268
+
269
+ def _db(config):
270
+ from ..storage import DatabaseManager, get_db_url
271
+
272
+ return DatabaseManager.instance(get_db_url(config=config))
273
+
274
+
275
+ def load_settings(config) -> dict:
276
+ from ..storage import CalendarSettingsRecord
277
+
278
+ with _db(config).get_session() as s:
279
+ rec = s.query(CalendarSettingsRecord).order_by(CalendarSettingsRecord.id).first() # one row per user
280
+ if rec is None:
281
+ return {**DEFAULTS, "notified_day": None}
282
+ return {c.name: getattr(rec, c.name) for c in rec.__table__.columns if c.name not in ("id", "user_id")}
283
+
284
+
285
+ def hidden(settings: dict) -> set[str]:
286
+ """Event type keys the user turned off."""
287
+ return {k for k in (settings.get("hidden_events") or "").split(",") if k in EVENT_KEYS}
288
+
289
+
290
+ def visible(items: list[dict], off: set[str], key: str) -> list[dict]:
291
+ return [e for e in items if e.get(key) not in off]
292
+
293
+
294
+ def save_settings(config, **changes) -> dict:
295
+ from ..storage import CalendarSettingsRecord
296
+
297
+ with _db(config).get_session() as s:
298
+ rec = s.query(CalendarSettingsRecord).order_by(CalendarSettingsRecord.id).first() # one row per user
299
+ if rec is None:
300
+ rec = CalendarSettingsRecord(**DEFAULTS)
301
+ s.add(rec)
302
+ for k, v in changes.items():
303
+ setattr(rec, k, v)
304
+ return load_settings(config)
305
+
306
+
307
+ # ------------------------------------------------------------------ custom events
308
+
309
+
310
+ def _add_months(d: date, months: int) -> date:
311
+ import calendar as pycal
312
+
313
+ m = d.year * 12 + d.month - 1 + months
314
+ y, mo = m // 12, m % 12 + 1
315
+ return date(y, mo, min(d.day, pycal.monthrange(y, mo)[1]))
316
+
317
+
318
+ def occurrences(rec: dict, start: date, days: int) -> list[date]:
319
+ """Dates of a custom event inside start..start+days (repeats roll forward;
320
+ the 31st falls on the month's last day, 29 Feb on 28 Feb)."""
321
+ try:
322
+ first = date.fromisoformat(rec["date"])
323
+ except (TypeError, ValueError):
324
+ return []
325
+ end = start + timedelta(days=days)
326
+ step = {"monthly": 1, "yearly": 12}.get(rec.get("repeat") or "none")
327
+ if not step:
328
+ return [first] if start <= first <= end else []
329
+ out, n = [], 0
330
+ if first < start: # jump close to the window instead of walking from the first date
331
+ n = max(0, ((start.year - first.year) * 12 + start.month - first.month) // step - 1)
332
+ while (d := _add_months(first, n * step)) <= end:
333
+ if d >= start:
334
+ out.append(d)
335
+ n += 1
336
+ return out
337
+
338
+
339
+ def list_custom(config) -> list[dict]:
340
+ from ..storage import CustomEventRecord
341
+
342
+ with _db(config).get_session() as s:
343
+ return [{"id": r.id, "title": r.title, "date": r.date, "repeat": r.repeat or "none", "note": r.note or ""}
344
+ for r in s.query(CustomEventRecord).order_by(CustomEventRecord.date, CustomEventRecord.id)]
345
+
346
+
347
+ def save_custom(config, event_id: Optional[int] = None, **fields) -> Optional[dict]:
348
+ """Create (no id) or update a custom event; None if that id is not the user's."""
349
+ from ..storage import CustomEventRecord
350
+
351
+ with _db(config).get_session() as s:
352
+ if event_id is None:
353
+ rec = CustomEventRecord(repeat="none")
354
+ s.add(rec)
355
+ else:
356
+ rec = s.query(CustomEventRecord).filter(CustomEventRecord.id == event_id).first()
357
+ if rec is None:
358
+ return None
359
+ for k in ("title", "date", "repeat", "note"):
360
+ if k in fields:
361
+ setattr(rec, k, fields[k])
362
+ s.flush()
363
+ return {"id": rec.id, "title": rec.title, "date": rec.date, "repeat": rec.repeat or "none", "note": rec.note or ""}
364
+
365
+
366
+ def delete_custom(config, event_id: int) -> bool:
367
+ from ..storage import CustomEventRecord
368
+
369
+ with _db(config).get_session() as s:
370
+ return bool(s.query(CustomEventRecord).filter(CustomEventRecord.id == event_id).delete())
371
+
372
+
373
+ def custom_events(records: list[dict], start: date, days: int) -> list[dict]:
374
+ """Custom events falling in the window, one row per occurrence, soonest first."""
375
+ out = []
376
+ for r in records:
377
+ for d in occurrences(r, start, days):
378
+ out.append({**r, "first_date": r["date"], "date": d.isoformat(), "days_away": (d - start).days,
379
+ "kind": "custom"})
380
+ out.sort(key=lambda e: (e["date"], e["title"]))
381
+ return out
382
+
383
+
384
+ # ------------------------------------------------------------------ reminders
385
+
386
+
387
+ def _when(e: dict) -> str:
388
+ return "today" if e["days_away"] == 0 else "tomorrow" if e["days_away"] == 1 else e["date"]
389
+
390
+
391
+ def digest(events: list[dict], economic: Optional[list[dict]] = None, custom: Optional[list[dict]] = None) -> str:
392
+ lines = []
393
+ if custom:
394
+ lines.append("📌 Your events")
395
+ for e in custom[:20]:
396
+ note = f" - {e['note']}" if e.get("note") else ""
397
+ lines.append(f"• {e['title']}: {_when(e)}{note}")
398
+ if events:
399
+ if lines:
400
+ lines.append("")
401
+ lines.append(f"📅 {len(events)} upcoming event{'s' if len(events) != 1 else ''} for stocks you follow")
402
+ for e in events[:25]:
403
+ extra = f" (EPS est. {e['eps_estimate']:.2f})" if e.get("eps_estimate") is not None else ""
404
+ detail = f" - {e['detail']}" if e.get("detail") and e["kind"] != "earnings" else ""
405
+ lines.append(f"• {e['symbol']}: {e['label']} {_when(e)}{extra}{detail}")
406
+ if len(events) > 25:
407
+ lines.append(f"…and {len(events) - 25} more (open Watchlist → Upcoming events)")
408
+ markets = [e for e in economic or [] if e.get("kind") == "market"]
409
+ economic = [e for e in economic or [] if e.get("kind") != "market"]
410
+ if markets:
411
+ if lines:
412
+ lines.append("")
413
+ lines.append("🏛 Markets")
414
+ for e in markets[:15]:
415
+ at = f" {e['local_time']}" if e.get("local_time") else ""
416
+ lines.append(f"• {e['title']}: {_when(e)}{at}")
417
+ if e.get("note"):
418
+ lines.append(f" {e['note']}")
419
+ if e.get("exposure") and e.get("key") in ("market_holiday", "short_day"):
420
+ shown = ", ".join(e["exposure"][:6]) + (f" +{len(e['exposure']) - 6} more" if len(e["exposure"]) > 6 else "")
421
+ lines.append(f" Your stocks there: {shown}")
422
+ if economic:
423
+ if lines:
424
+ lines.append("")
425
+ lines.append("🌐 Economic calendar")
426
+ for e in economic[:10]:
427
+ at = f" {e['local_time']}" if e.get("local_time") else ""
428
+ lines.append(f"• {e['title']} ({e['region']}): {_when(e)}{at}")
429
+ if e.get("last"):
430
+ lines.append(f" Last: {e['last']}")
431
+ if e.get("why"):
432
+ lines.append(f" Why it matters: {e['why']}")
433
+ if e.get("exposure"):
434
+ shown = ", ".join(e["exposure"][:4]) + (f" +{len(e['exposure']) - 4} more" if len(e["exposure"]) > 4 else "")
435
+ lines.append(f" Your {e['region']} stocks: {shown}")
436
+ return "\n".join(lines)
437
+
438
+
439
+ def _economic(start: date, days: int, tz: str, followed: Optional[dict] = None,
440
+ off: frozenset = frozenset()) -> list[dict]:
441
+ """Macro and market events (holidays, sessions, expiries) for the reminder
442
+ window that the user has on, with their last reading and why they matter;
443
+ never raises."""
444
+ try:
445
+ from . import econ_calendar
446
+
447
+ events = econ_calendar.collect(start, days)["events"] + _markets(start, days)
448
+ events.sort(key=lambda e: (e["date"], e["time"] or "", e["title"]))
449
+ for e in events:
450
+ e["local_time"] = econ_calendar.local_time(e, tz)
451
+ return visible(econ_calendar.enrich(events, followed), off, "key")
452
+ except Exception as exc:
453
+ logger.info("Economic calendar for reminders failed: %s", exc)
454
+ return []
455
+
456
+
457
+ def _markets(start: date, days: int) -> list[dict]:
458
+ try:
459
+ from . import market_calendar
460
+
461
+ return market_calendar.collect(start, days)["events"]
462
+ except Exception as exc:
463
+ logger.info("Market calendar failed: %s", exc)
464
+ return []
465
+
466
+
467
+ def _tz(settings: dict) -> ZoneInfo:
468
+ try:
469
+ return ZoneInfo(settings.get("timezone") or DEFAULTS["timezone"])
470
+ except Exception:
471
+ return ZoneInfo(DEFAULTS["timezone"])
472
+
473
+
474
+ def _local(now: Optional[datetime], tz: ZoneInfo) -> datetime:
475
+ if now is None:
476
+ return datetime.now(tz)
477
+ # a naive `now` is read as already in the settings' timezone
478
+ return now.astimezone(tz) if now.tzinfo else now.replace(tzinfo=tz)
479
+
480
+
481
+ def gather(config, day: date, window: int, followed: dict, off: set, tz: str, fetch: Fetch = _yf_calendar,
482
+ economic_fetch: Optional[Callable] = _economic, custom_fetch: Optional[Callable] = list_custom) -> dict:
483
+ """Everything due day..day+window for these stocks, minus the event types in `off`."""
484
+ events = visible(collect(followed, day, window, fetch), off, "kind") if followed else []
485
+ economic = economic_fetch(day, window, tz, followed, frozenset(off)) if economic_fetch else []
486
+ custom = custom_events(custom_fetch(config), day, window) if custom_fetch and "custom" not in off else []
487
+ return {"events": events, "economic": economic, "custom": custom}
488
+
489
+
490
+ def _send(config, dispatcher, found: dict, title: str) -> dict:
491
+ if not (found["events"] or found["economic"] or found["custom"]):
492
+ return {}
493
+ try:
494
+ from ..notifiers.dispatcher import NotificationDispatcher
495
+
496
+ dispatcher = dispatcher or NotificationDispatcher(config)
497
+ return dispatcher.send_all(digest(found["events"], found["economic"], found["custom"]),
498
+ title=title, purpose="alert")
499
+ except Exception as exc:
500
+ logger.error("%s failed: %s", title, exc)
501
+ return {}
502
+
503
+
504
+ def check_reminders(config, now: Optional[datetime] = None, dispatcher=None, fetch: Fetch = _yf_calendar,
505
+ force: bool = False, economic_fetch: Optional[Callable] = _economic,
506
+ custom_fetch: Optional[Callable] = list_custom) -> Optional[dict]:
507
+ """Monitor hook: once a day, after `notify_hour`, send one digest of events
508
+ due within `days_before` days. `force` sends now (the "Send test" button)."""
509
+ settings = load_settings(config)
510
+ if not settings.get("notify") and not force:
511
+ return None
512
+ tz = _tz(settings)
513
+ local = _local(now, tz)
514
+ today = local.strftime("%Y-%m-%d")
515
+ if not force and (settings.get("notified_day") == today or local.hour < int(settings.get("notify_hour") or 0)):
516
+ return None
517
+
518
+ window = int(settings.get("days_before") or 1)
519
+ found = gather(config, local.date(), window, my_symbols(config, settings), hidden(settings), str(tz),
520
+ fetch, economic_fetch, custom_fetch)
521
+ delivery = _send(config, dispatcher, found, "Upcoming events")
522
+ if not force:
523
+ save_settings(config, notified_day=today)
524
+ return {**found, "delivery": delivery}
525
+
526
+
527
+ # ------------------------------------------------------------------ named event alerts
528
+
529
+ ALERT_FIELDS = ("name", "enabled", "sources", "symbols", "event_types", "days_before", "notify_hour")
530
+
531
+
532
+ def _alert_dict(r) -> dict:
533
+ return {"id": r.id, "name": r.name, "enabled": bool(r.enabled), "sources": r.sources,
534
+ "symbols": r.symbols or [], "event_types": r.event_types, "days_before": r.days_before or 0,
535
+ "notify_hour": r.notify_hour if r.notify_hour is not None else 8, "notified_day": r.notified_day}
536
+
537
+
538
+ def list_alerts(config) -> list[dict]:
539
+ from ..storage import EventAlertRecord
540
+
541
+ with _db(config).get_session() as s:
542
+ return [_alert_dict(r) for r in s.query(EventAlertRecord).order_by(EventAlertRecord.id)]
543
+
544
+
545
+ def save_alert(config, alert_id: Optional[int] = None, **changes) -> Optional[dict]:
546
+ """Create (no id) or update an alert; None if that id is not the user's."""
547
+ from ..storage import EventAlertRecord
548
+
549
+ if "symbols" in changes:
550
+ changes["symbols"] = clean_symbols(changes["symbols"])
551
+ if changes.get("event_types") is not None:
552
+ changes["event_types"] = sorted(set(changes["event_types"]) & EVENT_KEYS)
553
+ with _db(config).get_session() as s:
554
+ if alert_id is None:
555
+ rec = EventAlertRecord(name="Event alert", enabled=True, days_before=1, notify_hour=8)
556
+ s.add(rec)
557
+ else:
558
+ rec = s.query(EventAlertRecord).filter(EventAlertRecord.id == alert_id).first()
559
+ if rec is None:
560
+ return None
561
+ for k, v in changes.items():
562
+ if k in ALERT_FIELDS or k == "notified_day":
563
+ setattr(rec, k, v)
564
+ if {"days_before", "notify_hour", "event_types", "sources", "symbols"} & set(changes) and "notified_day" not in changes:
565
+ rec.notified_day = None # a changed alert may go out again today
566
+ s.flush()
567
+ return _alert_dict(rec)
568
+
569
+
570
+ def delete_alert(config, alert_id: int) -> bool:
571
+ from ..storage import EventAlertRecord
572
+
573
+ with _db(config).get_session() as s:
574
+ return bool(s.query(EventAlertRecord).filter(EventAlertRecord.id == alert_id).delete())
575
+
576
+
577
+ def alert_off(alert: dict) -> set:
578
+ types = alert.get("event_types")
579
+ return set() if types is None else EVENT_KEYS - set(types)
580
+
581
+
582
+ def run_alert(config, alert: dict, local: datetime, dispatcher=None, fetch: Fetch = _yf_calendar,
583
+ economic_fetch: Optional[Callable] = _economic, custom_fetch: Optional[Callable] = list_custom) -> dict:
584
+ followed = followed_symbols(config, alert.get("sources"), alert.get("symbols"))
585
+ found = gather(config, local.date(), int(alert.get("days_before") or 0), followed, alert_off(alert),
586
+ str(local.tzinfo), fetch, economic_fetch, custom_fetch)
587
+ return {**found, "delivery": _send(config, dispatcher, found, alert.get("name") or "Event alert")}
588
+
589
+
590
+ def check_event_alerts(config, now: Optional[datetime] = None, dispatcher=None, fetch: Fetch = _yf_calendar,
591
+ economic_fetch: Optional[Callable] = _economic,
592
+ custom_fetch: Optional[Callable] = list_custom) -> list[str]:
593
+ """Monitor hook: each enabled alert goes out once a day after its hour; returns the names sent."""
594
+ alerts = [a for a in list_alerts(config) if a["enabled"]]
595
+ if not alerts:
596
+ return []
597
+ local = _local(now, _tz(load_settings(config)))
598
+ today, ran = local.strftime("%Y-%m-%d"), []
599
+ for a in alerts:
600
+ if a["notified_day"] == today or local.hour < a["notify_hour"]:
601
+ continue
602
+ try:
603
+ run_alert(config, a, local, dispatcher, fetch, economic_fetch, custom_fetch)
604
+ except Exception as exc:
605
+ logger.error("Event alert %s failed: %s", a["name"], exc)
606
+ save_alert(config, a["id"], notified_day=today)
607
+ ran.append(a["name"])
608
+ return ran
609
+
610
+
611
+ def result_message(done: list[dict]) -> str:
612
+ lines = []
613
+ for e in done:
614
+ lines.append(f"📊 {e['title']} is out: {e['result']}")
615
+ if e.get("verdict"):
616
+ lines.append(f" {e['verdict']}")
617
+ if e.get("exposure"):
618
+ shown = ", ".join(e["exposure"][:4]) + (f" +{len(e['exposure']) - 4} more" if len(e["exposure"]) > 4 else "")
619
+ lines.append(f" Your {e['region']} stocks: {shown}")
620
+ return "\n".join(lines)
621
+
622
+
623
+ def _today_economic(day: date) -> list[dict]:
624
+ from . import econ_calendar
625
+
626
+ return econ_calendar.collect(day, 0)["events"]
627
+
628
+
629
+ def _fresh_points() -> dict:
630
+ from . import econ_calendar
631
+
632
+ return econ_calendar.points(max_age=15 * 60) # shared by every user, re-read at most every 15 min
633
+
634
+
635
+ def check_results(config, now: Optional[datetime] = None, dispatcher=None,
636
+ today_events: Callable[[date], list[dict]] = _today_economic,
637
+ fetch_points: Callable[[], dict] = _fresh_points) -> Optional[list[dict]]:
638
+ """Monitor hook: once a US data release the user has on is out, send its
639
+ actual number against the one before - one message per release."""
640
+ settings = load_settings(config)
641
+ if not settings.get("notify"):
642
+ return None
643
+ local = _local(now, _tz(settings))
644
+ today = local.strftime("%Y-%m-%d")
645
+ sent = _sent_keys(settings, local.date())
646
+ off = hidden(settings)
647
+
648
+ from . import econ_calendar
649
+
650
+ try:
651
+ events = [e for e in today_events(local.date()) if (e.get("key") or "") not in off | sent]
652
+ done = econ_calendar.results(events, local, fetch_points)
653
+ except Exception as exc:
654
+ logger.info("Release results check failed: %s", exc)
655
+ return None
656
+ done = [e for e in done if e["key"] not in off | sent]
657
+ if not done:
658
+ return None
659
+ econ_calendar.enrich(done, my_symbols(config, settings), read=dict)
660
+ try:
661
+ from ..notifiers.dispatcher import NotificationDispatcher
662
+
663
+ dispatcher = dispatcher or NotificationDispatcher(config)
664
+ dispatcher.send_all(result_message(done), title="Economic data is out", purpose="alert")
665
+ except Exception as exc:
666
+ logger.error("Release result message failed: %s", exc)
667
+ save_settings(config, results_sent=f"{today}:{','.join(sorted(sent | {e['key'] for e in done}))}")
668
+ return done
669
+
670
+
671
+ # ------------------------------------------------------------------ company results
672
+
673
+ STOCK_KEEP_DAYS = 7 # "e:SYMBOL:date" sent-markers outlive the day; economic ones don't
674
+ EARNINGS_RECHECK = 30 * 60 # look for a fresh actual EPS at most this often per stock
675
+ _earnings_checked: dict[str, float] = {}
676
+
677
+
678
+ def _sent_keys(settings: dict, today: date) -> set[str]:
679
+ """Release / results markers already sent: today's economic keys plus
680
+ company results from the last STOCK_KEEP_DAYS."""
681
+ day, _, raw = (settings.get("results_sent") or "").partition(":")
682
+ keys = set(raw.split(",")) - {""}
683
+ keep = set()
684
+ for k in keys:
685
+ if k.startswith("e:"):
686
+ try:
687
+ if (today - date.fromisoformat(k.rsplit(":", 1)[1])).days <= STOCK_KEEP_DAYS:
688
+ keep.add(k)
689
+ except ValueError:
690
+ continue
691
+ elif day == today.isoformat():
692
+ keep.add(k)
693
+ return keep
694
+
695
+
696
+ def _recent_reports(config, followed: dict, day: date, fetch: Fetch = _yf_calendar) -> list[tuple[str, str]]:
697
+ """Followed stocks whose earnings date was in the last two days."""
698
+ return [(e["symbol"], e["market"]) for e in collect(followed, day - timedelta(days=2), 2, fetch)
699
+ if e["kind"] == "earnings" and e["days_away"] <= 2]
700
+
701
+
702
+ def check_earnings_results(config, now: Optional[datetime] = None, dispatcher=None,
703
+ reports: Optional[Callable] = None, build: Optional[Callable] = None) -> Optional[list[str]]:
704
+ """Monitor hook: once a stock in the user's events list has reported and
705
+ Yahoo has the actual EPS, send actual vs expected and the price move - once."""
706
+ settings = load_settings(config)
707
+ off = hidden(settings)
708
+ if not settings.get("notify") or "earnings" in off:
709
+ return None
710
+ local = _local(now, _tz(settings))
711
+ sent = _sent_keys(settings, local.date())
712
+ try:
713
+ candidates = (reports or _recent_reports)(config, my_symbols(config, settings), local.date())
714
+ except Exception as exc:
715
+ logger.info("Earnings results check failed: %s", exc)
716
+ return None
717
+ if build is None:
718
+ from .earnings import build
719
+ from .earnings import results_line
720
+
721
+ lines, new_keys = [], set()
722
+ clock = time.time()
723
+ for symbol, market in candidates:
724
+ if any(k.startswith(f"e:{symbol}:") for k in sent):
725
+ continue
726
+ if clock - _earnings_checked.get(f"{symbol}:{market}", 0) < EARNINGS_RECHECK:
727
+ continue
728
+ _earnings_checked[f"{symbol}:{market}"] = clock
729
+ try:
730
+ data = build(symbol, market, with_options=False, use_cache=False)
731
+ except Exception as exc:
732
+ logger.info("Earnings for %s unavailable: %s", symbol, exc)
733
+ continue
734
+ line = results_line(symbol, data)
735
+ if line:
736
+ lines.append(f"• {line}")
737
+ new_keys.add(f"e:{symbol}:{data['latest']['date']}")
738
+ if not lines:
739
+ return None
740
+ try:
741
+ from ..notifiers.dispatcher import NotificationDispatcher
742
+
743
+ dispatcher = dispatcher or NotificationDispatcher(config)
744
+ dispatcher.send_all("📊 Results are out\n" + "\n".join(lines), title="Results are out", purpose="alert")
745
+ except Exception as exc:
746
+ logger.error("Earnings results message failed: %s", exc)
747
+ save_settings(config, results_sent=f"{local.date().isoformat()}:{','.join(sorted(sent | new_keys))}")
748
+ return lines