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,269 @@
1
+ """Event calendar: upcoming earnings / dividend dates for followed symbols,
2
+ economic releases, market holidays and sessions, and the daily reminder
3
+ settings (marketmyna/services/calendar.py)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from datetime import datetime
9
+ from typing import Optional
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
13
+ from pydantic import BaseModel, Field
14
+
15
+ from marketmyna.api.deps import get_config_dep
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ class SymbolIn(BaseModel):
21
+ symbol: str = Field(min_length=1, max_length=40)
22
+ market: str = Field(default="us", max_length=32)
23
+
24
+
25
+ class CalendarSettingsIn(BaseModel):
26
+ notify: Optional[bool] = None
27
+ days_before: Optional[int] = Field(default=None, ge=0, le=14)
28
+ notify_hour: Optional[int] = Field(default=None, ge=0, le=23)
29
+ timezone: Optional[str] = None
30
+ hidden_events: Optional[list[str]] = None
31
+ # "My events list": the lists that count ("w:<id>" / "p:<id>"); all_lists=True goes back to every list
32
+ sources: Optional[list[str]] = Field(default=None, max_length=200)
33
+ all_lists: Optional[bool] = None
34
+ extra_symbols: Optional[list[SymbolIn]] = Field(default=None, max_length=200)
35
+
36
+
37
+ class AlertIn(BaseModel):
38
+ name: Optional[str] = Field(default=None, min_length=1, max_length=80)
39
+ enabled: Optional[bool] = None
40
+ sources: Optional[list[str]] = Field(default=None, max_length=200)
41
+ all_lists: Optional[bool] = None # back to every list (sources = null)
42
+ symbols: Optional[list[SymbolIn]] = Field(default=None, max_length=200)
43
+ event_types: Optional[list[str]] = None
44
+ all_types: Optional[bool] = None
45
+ days_before: Optional[int] = Field(default=None, ge=0, le=14)
46
+ notify_hour: Optional[int] = Field(default=None, ge=0, le=23)
47
+
48
+
49
+ class CustomIn(BaseModel):
50
+ title: Optional[str] = Field(default=None, min_length=1, max_length=120)
51
+ date: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$")
52
+ repeat: Optional[str] = None
53
+ note: Optional[str] = Field(default=None, max_length=500)
54
+
55
+
56
+ MAX_ALERTS = 20
57
+ MAX_CUSTOM = 200
58
+ _last_send: dict[str, float] = {}
59
+
60
+
61
+ def _throttle(request: Request, what: str) -> None:
62
+ """One manual send a minute per user (they go to real channels)."""
63
+ from marketmyna.core import tenant
64
+
65
+ key = f"{tenant.current_user_id() or request.client.host if request.client else ''}:{what}"
66
+ now = time.time()
67
+ if now - _last_send.get(key, 0) < 60:
68
+ raise HTTPException(status_code=429, detail="Sent a moment ago - try again in a minute.")
69
+ _last_send[key] = now
70
+
71
+
72
+ def _check_types(keys: list[str]) -> None:
73
+ from marketmyna.services.calendar import EVENT_KEYS
74
+
75
+ unknown = set(keys) - EVENT_KEYS
76
+ if unknown:
77
+ raise HTTPException(status_code=400, detail=f"Unknown event types: {', '.join(sorted(unknown))}")
78
+
79
+
80
+ def _public(settings: dict) -> dict:
81
+ from marketmyna.services.calendar import hidden
82
+
83
+ out = {k: settings[k] for k in ("notify", "days_before", "notify_hour", "timezone")}
84
+ return {**out, "hidden_events": sorted(hidden(settings)), "sources": settings.get("sources"),
85
+ "extra_symbols": settings.get("extra_symbols") or []}
86
+
87
+
88
+ @router.get("/calendar")
89
+ def upcoming_events(days: int = Query(default=30, ge=1, le=120), config=Depends(get_config_dep)) -> dict:
90
+ from marketmyna.services import econ_calendar, market_calendar
91
+ from marketmyna.services.calendar import (
92
+ EVENT_TYPES,
93
+ _fresh_points,
94
+ collect,
95
+ custom_events,
96
+ hidden,
97
+ list_alerts,
98
+ list_custom,
99
+ list_sources,
100
+ load_settings,
101
+ my_symbols,
102
+ visible,
103
+ )
104
+ from marketmyna.services.schedule_config import source_catalog
105
+
106
+ settings = load_settings(config)
107
+ catalog = source_catalog(config)
108
+ symbols = my_symbols(config, settings, catalog)
109
+ tz = settings.get("timezone") or "Asia/Kolkata"
110
+ today = datetime.now(ZoneInfo(tz)).date()
111
+ econ = econ_calendar.collect(today, days)
112
+ markets = market_calendar.collect(today, days)
113
+ econ["events"] = sorted(econ["events"] + markets["events"], key=lambda e: (e["date"], e["time"] or "", e["title"]))
114
+ econ["warnings"] += markets["warnings"]
115
+ for e in econ["events"]:
116
+ e["local_time"] = econ_calendar.local_time(e, tz)
117
+ econ_calendar.enrich(econ["events"], symbols)
118
+ now = datetime.now(ZoneInfo(tz))
119
+ for done in econ_calendar.results([e for e in econ["events"] if e["days_away"] == 0], now, _fresh_points):
120
+ for e in econ["events"]:
121
+ if e["date"] == done["date"] and e["key"] == done["key"]:
122
+ e["result"], e["verdict"] = done["result"], done["verdict"]
123
+ off = hidden(settings)
124
+ custom = list_custom(config)
125
+ return {"from": today.isoformat(), "days": days, "symbols": len(symbols),
126
+ "events": visible(collect(symbols, today, days), off, "kind"),
127
+ "economic": visible(econ["events"], off, "key"),
128
+ "custom": custom_events(custom, today, days) if "custom" not in off else [],
129
+ "custom_all": custom,
130
+ "economic_warnings": econ["warnings"], "settings": _public(settings),
131
+ "lists": list_sources(config, catalog), "alerts": list_alerts(config),
132
+ "event_types": [{"key": k, "group": g, "name": n, "about": a} for k, g, n, a in EVENT_TYPES]}
133
+
134
+
135
+ @router.put("/calendar/settings")
136
+ def update_settings(body: CalendarSettingsIn, config=Depends(get_config_dep)) -> dict:
137
+ from marketmyna.services.calendar import clean_symbols, save_settings
138
+
139
+ changes = body.model_dump(exclude_none=True)
140
+ if "hidden_events" in changes:
141
+ _check_types(changes["hidden_events"])
142
+ changes["hidden_events"] = ",".join(sorted(set(changes["hidden_events"]))) or None
143
+ if "timezone" in changes:
144
+ try:
145
+ ZoneInfo(changes["timezone"])
146
+ except Exception:
147
+ raise HTTPException(status_code=400, detail=f"Unknown timezone '{changes['timezone']}'")
148
+ if changes.pop("all_lists", False):
149
+ changes["sources"] = None
150
+ if "extra_symbols" in changes:
151
+ changes["extra_symbols"] = clean_symbols(changes["extra_symbols"])
152
+ return _public(save_settings(config, **changes))
153
+
154
+
155
+ @router.post("/calendar/remind-now")
156
+ def remind_now(request: Request, config=Depends(get_config_dep)) -> dict:
157
+ """Send today's digest right away (does not count as the daily one)."""
158
+ from marketmyna.services.calendar import check_reminders
159
+
160
+ _throttle(request, "digest")
161
+ result = check_reminders(config, force=True) or {"events": [], "delivery": {}}
162
+ count = len(result["events"]) + len(result.get("economic", [])) + len(result.get("custom", []))
163
+ return {"sent": bool(count), "count": count, "delivery": result["delivery"]}
164
+
165
+
166
+ # ------------------------------------------------------------------ named event alerts
167
+
168
+
169
+ def _alert_changes(body: AlertIn) -> dict:
170
+ changes = body.model_dump(exclude_none=True)
171
+ if "event_types" in changes:
172
+ _check_types(changes["event_types"])
173
+ if changes.pop("all_types", False):
174
+ changes["event_types"] = None
175
+ if changes.pop("all_lists", False):
176
+ changes["sources"] = None
177
+ return changes
178
+
179
+
180
+ @router.post("/calendar/alerts", status_code=201)
181
+ def create_alert(body: AlertIn, config=Depends(get_config_dep)) -> dict:
182
+ from marketmyna.services.calendar import list_alerts, save_alert
183
+
184
+ if len(list_alerts(config)) >= MAX_ALERTS:
185
+ raise HTTPException(status_code=400, detail=f"Up to {MAX_ALERTS} event alerts.")
186
+ return save_alert(config, None, **_alert_changes(body))
187
+
188
+
189
+ @router.put("/calendar/alerts/{alert_id}")
190
+ def update_alert(alert_id: int, body: AlertIn, config=Depends(get_config_dep)) -> dict:
191
+ from marketmyna.services.calendar import save_alert
192
+
193
+ out = save_alert(config, alert_id, **_alert_changes(body))
194
+ if out is None:
195
+ raise HTTPException(status_code=404, detail="Alert not found")
196
+ return out
197
+
198
+
199
+ @router.delete("/calendar/alerts/{alert_id}", status_code=204)
200
+ def remove_alert(alert_id: int, config=Depends(get_config_dep)) -> None:
201
+ from marketmyna.services.calendar import delete_alert
202
+
203
+ if not delete_alert(config, alert_id):
204
+ raise HTTPException(status_code=404, detail="Alert not found")
205
+
206
+
207
+ @router.post("/calendar/alerts/{alert_id}/send-now")
208
+ def send_alert_now(alert_id: int, request: Request, config=Depends(get_config_dep)) -> dict:
209
+ """Run one alert right away (does not count as its daily run)."""
210
+ from marketmyna.services.calendar import _local, _tz, list_alerts, load_settings, run_alert
211
+
212
+ alert = next((a for a in list_alerts(config) if a["id"] == alert_id), None)
213
+ if alert is None:
214
+ raise HTTPException(status_code=404, detail="Alert not found")
215
+ _throttle(request, f"alert{alert_id}")
216
+ result = run_alert(config, alert, _local(None, _tz(load_settings(config))))
217
+ count = len(result["events"]) + len(result["economic"]) + len(result["custom"])
218
+ return {"sent": bool(count), "count": count, "delivery": result["delivery"]}
219
+
220
+
221
+ # ------------------------------------------------------------------ custom events
222
+
223
+
224
+ def _custom_fields(body: CustomIn) -> dict:
225
+ from datetime import date as _date
226
+
227
+ from marketmyna.services.calendar import REPEATS
228
+
229
+ fields = body.model_dump(exclude_none=True)
230
+ if "date" in fields:
231
+ try:
232
+ _date.fromisoformat(fields["date"])
233
+ except ValueError:
234
+ raise HTTPException(status_code=400, detail="Not a real date")
235
+ if "repeat" in fields and fields["repeat"] not in REPEATS:
236
+ raise HTTPException(status_code=400, detail="Repeat must be none, monthly or yearly")
237
+ if "title" in fields:
238
+ fields["title"] = fields["title"].strip()
239
+ return fields
240
+
241
+
242
+ @router.post("/calendar/custom-events", status_code=201)
243
+ def create_custom(body: CustomIn, config=Depends(get_config_dep)) -> dict:
244
+ from marketmyna.services.calendar import list_custom, save_custom
245
+
246
+ fields = _custom_fields(body)
247
+ if not fields.get("title") or not fields.get("date"):
248
+ raise HTTPException(status_code=400, detail="A title and a date are needed")
249
+ if len(list_custom(config)) >= MAX_CUSTOM:
250
+ raise HTTPException(status_code=400, detail=f"Up to {MAX_CUSTOM} of your own events.")
251
+ return save_custom(config, None, **fields)
252
+
253
+
254
+ @router.put("/calendar/custom-events/{event_id}")
255
+ def update_custom(event_id: int, body: CustomIn, config=Depends(get_config_dep)) -> dict:
256
+ from marketmyna.services.calendar import save_custom
257
+
258
+ out = save_custom(config, event_id, **_custom_fields(body))
259
+ if out is None:
260
+ raise HTTPException(status_code=404, detail="Event not found")
261
+ return out
262
+
263
+
264
+ @router.delete("/calendar/custom-events/{event_id}", status_code=204)
265
+ def remove_custom(event_id: int, config=Depends(get_config_dep)) -> None:
266
+ from marketmyna.services.calendar import delete_custom
267
+
268
+ if not delete_custom(config, event_id):
269
+ raise HTTPException(status_code=404, detail="Event not found")
@@ -0,0 +1,174 @@
1
+ """Optional data-source keys (Settings -> Data sources): live US prices,
2
+ backup price sources and Reddit. Secrets are never sent back, only whether
3
+ they are set (and the last 4). A value from an environment variable can't be
4
+ changed here."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+
10
+ from marketmyna.api.deps import get_config_dep
11
+ from marketmyna.api.middlewares.auth import accounts_db, require_admin
12
+
13
+ router = APIRouter(prefix="/system-config/data-sources", dependencies=[Depends(require_admin)])
14
+
15
+
16
+ def _db(config):
17
+ return accounts_db(config)
18
+
19
+
20
+ INTEGRATIONS = [
21
+ {
22
+ "id": "finnhub",
23
+ "title": "Finnhub live prices (US)",
24
+ "help": "Real-time US stock prices for the Markets and Watchlist tables (trade by trade). "
25
+ "Free key at finnhub.io -> Dashboard. The free plan streams about 50 symbols at once; "
26
+ "without a key, US prices come from the default source.",
27
+ "fields": [("finnhub_api_key", "API key", "secret")],
28
+ },
29
+ {
30
+ "id": "market_data",
31
+ "title": "Backup price sources",
32
+ "help": "Used only when the default price source fails or rate-limits, tried in this order. All have free keys: "
33
+ "twelvedata.com (800 calls/day, also NSE / BSE), tiingo.com, alphavantage.co (25 calls/day), "
34
+ "polygon.io (5 calls/min, daily bars). US stocks also fall back to Nasdaq and crypto to CoinGecko, "
35
+ "which need no key (a free CoinGecko demo key raises its rate limit).",
36
+ "fields": [
37
+ ("twelvedata_api_key", "Twelve Data API key", "secret"),
38
+ ("tiingo_api_key", "Tiingo API token", "secret"),
39
+ ("alpha_vantage_key", "Alpha Vantage API key", "secret"),
40
+ ("polygon_key", "Polygon API key", "secret"),
41
+ ("coingecko_api_key", "CoinGecko demo key (optional, crypto)", "secret"),
42
+ ],
43
+ },
44
+ {
45
+ "id": "social",
46
+ "title": "Social sentiment (Reddit, StockTwits)",
47
+ "help": "Reddit is read with a headless browser (Playwright) unless API keys are set - Reddit blocks "
48
+ "scrapers quickly, especially from cloud servers. For the API: reddit.com/prefs/apps -> create a "
49
+ "'script' app; mode 'auto' then uses the API first. StockTwits always needs the browser. "
50
+ "Install it with: pip install playwright && python -m playwright install --with-deps chromium",
51
+ "fields": [
52
+ ("reddit_client_id", "Reddit client ID", "text"),
53
+ ("reddit_client_secret", "Reddit client secret", "secret"),
54
+ ("reddit_user_agent", "Reddit user agent (e.g. MarketMyna/1.0 by u/you)", "text"),
55
+ ("social_reddit_mode", "Reddit mode: auto | api | playwright", "text"),
56
+ ("social_playwright", "Allow headless-browser scraping", "bool"),
57
+ ],
58
+ },
59
+ ]
60
+ _FIELD_KINDS = {name: kind for group in INTEGRATIONS for name, _label, kind in group["fields"]}
61
+
62
+
63
+ def _integrations(config) -> dict:
64
+ env = getattr(config, "_env_fields", set())
65
+ groups = []
66
+ for group in INTEGRATIONS:
67
+ fields = []
68
+ for name, label, kind in group["fields"]:
69
+ value = getattr(config, name, None)
70
+ item = {"name": name, "label": label, "kind": kind, "from_env": name in env}
71
+ if kind == "secret":
72
+ item["is_set"] = bool(value)
73
+ item["last4"] = value[-4:] if value and len(value) > 8 else ""
74
+ else:
75
+ item["value"] = value
76
+ fields.append(item)
77
+ groups.append({"id": group["id"], "title": group["title"], "help": group["help"], "fields": fields})
78
+ return {"groups": groups}
79
+
80
+
81
+ @router.get("")
82
+ def get_data_sources(config=Depends(get_config_dep)) -> dict:
83
+ _db(config)
84
+ return _integrations(config)
85
+
86
+
87
+ @router.put("")
88
+ def update_data_sources(body: dict, config=Depends(get_config_dep)) -> dict:
89
+ """Partial update: only the fields present change; null or "" clears one."""
90
+ _db(config)
91
+ env = getattr(config, "_env_fields", set())
92
+ changes = {}
93
+ for name, value in body.items():
94
+ kind = _FIELD_KINDS.get(name)
95
+ if kind is None:
96
+ raise HTTPException(status_code=400, detail=f"Unknown setting '{name}'.")
97
+ if name in env:
98
+ raise HTTPException(status_code=400, detail=f"{name.upper()} is set by an environment variable on the server.")
99
+ if value in (None, ""):
100
+ changes[name] = None
101
+ elif kind == "bool":
102
+ changes[name] = bool(value)
103
+ else:
104
+ value = str(value).strip()
105
+ if len(value) > 500:
106
+ raise HTTPException(status_code=400, detail="Value is too long.")
107
+ changes[name] = value
108
+ for name, value in changes.items():
109
+ setattr(config, name, value)
110
+ config.save()
111
+ return _integrations(config)
112
+
113
+
114
+ def _test_market_data() -> dict:
115
+ """One quote per configured source, straight from that source."""
116
+ from marketmyna.data import extra_sources as xs
117
+
118
+ xs._key_cache.clear() # a key saved a moment ago counts
119
+ checks = [
120
+ ("Twelve Data", xs.twelvedata_key, lambda: xs.twelvedata_quote("RELIANCE", "india"), "₹"),
121
+ ("Tiingo", xs.tiingo_key, lambda: xs.tiingo_quote("AAPL"), "$"),
122
+ ("Alpha Vantage", xs.alphavantage_key, lambda: xs.alphavantage_quote("AAPL"), "$"),
123
+ ("Polygon", xs.polygon_key, lambda: xs.polygon_quote("AAPL"), "$"),
124
+ ("Nasdaq", lambda: True, lambda: xs.nasdaq_quote("AAPL"), "$"),
125
+ ("CoinGecko", lambda: True, lambda: xs.coingecko_quote("BTC"), "$"),
126
+ ]
127
+ parts, failed = [], []
128
+ for name, has_key, fetch, cur in checks:
129
+ if not has_key():
130
+ continue
131
+ try:
132
+ q = fetch()
133
+ parts.append(f"{name} OK ({q['symbol']} {cur}{q['price']:,.2f})")
134
+ except Exception as exc: # noqa: BLE001 - report every source, not just the first failure
135
+ failed.append(f"{name}: {str(exc)[:120]}")
136
+ if failed:
137
+ raise HTTPException(status_code=400, detail="; ".join(failed + parts))
138
+ return {"ok": True, "message": ". ".join(parts) + "."}
139
+
140
+
141
+ @router.post("/{group_id}/test")
142
+ def test_data_source(group_id: str, config=Depends(get_config_dep)) -> dict:
143
+ if group_id == "finnhub":
144
+ import requests
145
+
146
+ key = getattr(config, "finnhub_api_key", None)
147
+ if not key:
148
+ raise HTTPException(status_code=400, detail="Enter the Finnhub API key first.")
149
+ try:
150
+ q = requests.get("https://finnhub.io/api/v1/quote", params={"symbol": "AAPL", "token": key}, timeout=10)
151
+ except requests.RequestException as exc:
152
+ raise HTTPException(status_code=400, detail=f"Finnhub unreachable: {exc}")
153
+ if q.status_code in (401, 403):
154
+ raise HTTPException(status_code=400, detail="Finnhub rejected the API key.")
155
+ price = (q.json() if q.ok else {}).get("c")
156
+ return {"ok": True, "message": f"Finnhub connected. AAPL ${price:,.2f}." if price else "Finnhub connected."}
157
+ if group_id == "market_data":
158
+ return _test_market_data()
159
+ if group_id == "social":
160
+ from marketmyna.data.social import browser, reddit
161
+
162
+ parts = ["Headless browser: " + ("installed." if browser.available() else "not installed - scraping is off.")]
163
+ client_id, secret = getattr(config, "reddit_client_id", None), getattr(config, "reddit_client_secret", None)
164
+ if client_id and secret:
165
+ try:
166
+ reddit._token.update(value=None, expires=0.0)
167
+ reddit._api_token(client_id, secret, getattr(config, "reddit_user_agent", None) or "MarketMyna/1.0")
168
+ except Exception as exc:
169
+ raise HTTPException(status_code=400, detail=f"Reddit API: {exc}")
170
+ parts.append("Reddit API keys work.")
171
+ else:
172
+ parts.append("No Reddit API keys - Reddit will be scraped.")
173
+ return {"ok": True, "message": " ".join(parts)}
174
+ raise HTTPException(status_code=404, detail="Nothing to test here.")
@@ -0,0 +1,10 @@
1
+ """Health check endpoint."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/health")
9
+ def health() -> dict:
10
+ return {"status": "ok"}