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,377 @@
1
+ """Application configuration management."""
2
+
3
+ import json
4
+ import os
5
+ import typing
6
+ from dataclasses import dataclass, field, fields
7
+ from pathlib import Path
8
+ from typing import Mapping, Optional
9
+
10
+ from marketmyna.core.home import app_home, db_filename
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ """Application configuration."""
16
+
17
+ # Paths
18
+ app_dir: Path = field(default_factory=lambda: app_home())
19
+ data_dir: Path = field(default_factory=lambda: app_home() / "data")
20
+ cache_dir: Path = field(default_factory=lambda: app_home() / "cache")
21
+
22
+ # Database
23
+ db_path: Path = field(default_factory=lambda: app_home() / db_filename(app_home()))
24
+ # Multi-user accounts (marketmyna/services/accounts.py). The web API always turns
25
+ # this on (marketmyna/api/middlewares/auth.py check_auth_config); it stays off for
26
+ # CLI runs, which have one local user.
27
+ # ADMIN_EMAIL: the account with this email gets the admin role.
28
+ # APP_BASE_URL: where email links (verify, reset) point.
29
+ # SECRET_KEY: encrypts users' own AI keys; without it a key file is
30
+ # generated under app_dir (fine locally, set it on a server).
31
+ accounts_enabled: bool = False
32
+ admin_email: Optional[str] = None
33
+ app_base_url: Optional[str] = None
34
+ secret_key: Optional[str] = None
35
+ # Shown on public pages and in account emails.
36
+ support_email: Optional[str] = ""
37
+ # Live NSE prices from Angel One SmartAPI (marketmyna/services/brokers/angel.py):
38
+ # app API key, client code, login PIN and the TOTP secret (base32).
39
+ angel_api_key: Optional[str] = None
40
+ angel_client_code: Optional[str] = None
41
+ angel_pin: Optional[str] = None
42
+ angel_totp_secret: Optional[str] = None
43
+ # Real-time US prices from Finnhub (marketmyna/services/brokers/finnhub.py).
44
+ finnhub_api_key: Optional[str] = None
45
+ # Social sentiment (marketmyna/data/social/): Reddit API app keys are optional -
46
+ # without them Reddit is scraped with Playwright. social_reddit_mode:
47
+ # auto (API when keys set, else scraping) | api | playwright.
48
+ reddit_client_id: Optional[str] = None
49
+ reddit_client_secret: Optional[str] = None
50
+ reddit_user_agent: Optional[str] = None
51
+ social_reddit_mode: str = "auto"
52
+ social_playwright: bool = True
53
+
54
+ # Backup price sources when Yahoo fails (marketmyna/data/extra_sources.py);
55
+ # all optional, free tiers. Twelve Data also covers NSE / BSE.
56
+ alpha_vantage_key: Optional[str] = None
57
+ polygon_key: Optional[str] = None
58
+ twelvedata_api_key: Optional[str] = None
59
+ tiingo_api_key: Optional[str] = None
60
+ coingecko_api_key: Optional[str] = None # optional "demo" key: higher rate limit
61
+
62
+ # Notification channels (all optional; a channel is used only when its
63
+ # required fields are set - see marketmyna/notifiers/*.py for what each needs)
64
+ telegram_bot_token: Optional[str] = None
65
+ telegram_chat_id: Optional[str] = None
66
+ discord_webhook_url: Optional[str] = None
67
+ slack_webhook_url: Optional[str] = None
68
+ email_sender: Optional[str] = None
69
+ # SMTP login when it differs from the sender, e.g. Gmail "Send mail as":
70
+ # log in as the Gmail address, send as support@ / reports@ (verified aliases).
71
+ email_username: Optional[str] = None
72
+ # From address of scheduled reports and alerts; blank = email_sender.
73
+ email_reports_sender: Optional[str] = None
74
+ email_password: Optional[str] = None
75
+ email_receivers: Optional[list] = None
76
+ email_smtp_server: Optional[str] = None
77
+ email_smtp_port: Optional[int] = None
78
+ email_smtp_ssl: Optional[bool] = None
79
+ dingtalk_webhook_url: Optional[str] = None
80
+ dingtalk_secret: Optional[str] = None
81
+ feishu_webhook_url: Optional[str] = None
82
+ feishu_webhook_secret: Optional[str] = None
83
+ wechat_webhook_url: Optional[str] = None
84
+ pushover_user_key: Optional[str] = None
85
+ pushover_api_token: Optional[str] = None
86
+ ntfy_url: Optional[str] = None
87
+ ntfy_token: Optional[str] = None
88
+ gotify_url: Optional[str] = None
89
+ gotify_token: Optional[str] = None
90
+ pushplus_token: Optional[str] = None
91
+ pushplus_topic: Optional[str] = None
92
+ serverchan3_sendkey: Optional[str] = None
93
+ astrbot_url: Optional[str] = None
94
+ astrbot_token: Optional[str] = None
95
+ webhook_url: Optional[str] = None
96
+ webhook_headers: Optional[str] = None
97
+ telegram_message_thread_id: Optional[str] = None
98
+ teams_webhook_url: Optional[str] = None
99
+ google_chat_webhook_url: Optional[str] = None
100
+ matrix_homeserver: Optional[str] = None
101
+ matrix_access_token: Optional[str] = None
102
+ matrix_room_id: Optional[str] = None
103
+ whatsapp_phone: Optional[str] = None
104
+ whatsapp_callmebot_apikey: Optional[str] = None
105
+ twilio_account_sid: Optional[str] = None
106
+ twilio_auth_token: Optional[str] = None
107
+ twilio_from: Optional[str] = None
108
+ twilio_to: Optional[list] = None
109
+ bark_url: Optional[str] = None
110
+ pushbullet_token: Optional[str] = None
111
+ signal_api_url: Optional[str] = None
112
+ signal_number: Optional[str] = None
113
+ signal_recipients: Optional[list] = None
114
+ apprise_urls: Optional[list] = None
115
+ web_push_vapid_private_key: Optional[str] = None
116
+ web_push_contact: Optional[str] = None
117
+ # Which channels get which kind of message (empty = every configured
118
+ # channel). Env names match daily_stock_analysis.
119
+ notification_report_channels: Optional[list] = None
120
+ notification_alert_channels: Optional[list] = None
121
+ notification_system_error_channels: Optional[list] = None
122
+
123
+ # LLM backend (see marketmyna/llm/*.py). llm_model uses litellm's own model-
124
+ # string convention so one field covers cloud providers and local
125
+ # Ollama alike, e.g. "gpt-4o-mini", "claude-3-5-sonnet-20241022",
126
+ # "ollama/qwen3:8b" (with llm_api_base pointed at the local server).
127
+ llm_backend: Optional[str] = "litellm"
128
+ llm_model: Optional[str] = None
129
+ llm_api_base: Optional[str] = None
130
+ llm_api_key: Optional[str] = None
131
+ llm_timeout_seconds: Optional[float] = 60.0
132
+ llm_max_tokens: Optional[int] = 2048
133
+ llm_temperature: Optional[float] = 0.7
134
+ # Failover (marketmyna/llm/channels.py): extra keys rotated for llm_model,
135
+ # models tried when it fails, and named channels whose settings come
136
+ # from LLM_<NAME>_* environment variables.
137
+ llm_api_keys: Optional[list] = None
138
+ llm_fallback_models: Optional[list] = None
139
+ llm_channels: Optional[list] = None
140
+ # Bring-your-own keys per provider (env: GEMINI_API_KEY, GROQ_API_KEY,
141
+ # ...). Any route whose model belongs to that provider uses its key.
142
+ # llm_fast_model: optional cheaper/faster "level 1" model for simple
143
+ # jobs (commentary, news tone, translation); decisions use llm_model.
144
+ # llm_cache_hours: reuse identical AI answers this long (0 = off).
145
+ gemini_api_key: Optional[str] = None
146
+ groq_api_key: Optional[str] = None
147
+ openrouter_api_key: Optional[str] = None
148
+ openai_api_key: Optional[str] = None
149
+ anthropic_api_key: Optional[str] = None
150
+ deepseek_api_key: Optional[str] = None
151
+ mistral_api_key: Optional[str] = None
152
+ cerebras_api_key: Optional[str] = None
153
+ nvidia_api_key: Optional[str] = None
154
+ ollama_base_url: Optional[str] = None
155
+ openai_base_url: Optional[str] = None
156
+ llm_fast_model: Optional[str] = None
157
+ llm_cache_hours: Optional[float] = None
158
+
159
+ # Scheduled reports: stocks analysed in parallel (None = up to 4 by CPU count).
160
+ scheduler_workers: Optional[int] = None
161
+
162
+ # UI Settings
163
+ theme: str = "dark"
164
+ window_width: int = 1400
165
+ window_height: int = 900
166
+
167
+ # Data Settings
168
+ cache_expiry_minutes: int = 15
169
+ default_market: str = "US"
170
+
171
+ def __post_init__(self):
172
+ """Create necessary directories."""
173
+ self._env_fields: set[str] = set()
174
+ self._plaintext_secrets = False
175
+ self.app_dir.mkdir(parents=True, exist_ok=True)
176
+ self.data_dir.mkdir(parents=True, exist_ok=True)
177
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
178
+
179
+ @classmethod
180
+ def load(cls, config_path: Optional[Path] = None) -> "Config":
181
+ """Load configuration from file."""
182
+ if config_path is None:
183
+ config_path = app_home() / "config.json"
184
+
185
+ if config_path.exists():
186
+ with open(config_path, encoding="utf-8") as f:
187
+ data = json.load(f)
188
+ # An older file may hold null for a field that now has a default,
189
+ # or settings this build no longer has: skip those.
190
+ known = {f.name for f in fields(cls)}
191
+ data = {k: v for k, v in data.items() if k in known and not (k == "support_email" and v in (None, ""))}
192
+ config = cls(**data).apply_env()
193
+ else:
194
+ config = cls().apply_env()
195
+ # Secrets are stored encrypted (marketmyna/core/secret_store.py); decrypt in memory.
196
+ from .secret_store import decrypt_config
197
+
198
+ config._plaintext_secrets = decrypt_config(config)
199
+ return config
200
+
201
+ def apply_env(self, environ: Optional[Mapping[str, str]] = None) -> "Config":
202
+ """Override fields from environment variables named after them in
203
+ upper case (TELEGRAM_BOT_TOKEN, LLM_MODEL, EMAIL_RECEIVERS, ...).
204
+
205
+ This is how headless runs (cron, GitHub Actions) are
206
+ configured: secrets arrive as env vars and no config.json exists.
207
+ Empty values are ignored, so an unset variable passed through as
208
+ "" never blanks a saved setting.
209
+ """
210
+ environ = os.environ if environ is None else environ
211
+ for f in fields(self):
212
+ raw = environ.get(f.name.upper())
213
+ if raw is None or raw.strip() == "":
214
+ continue
215
+ try:
216
+ setattr(self, f.name, _coerce(f.type, raw.strip()))
217
+ except ValueError as exc:
218
+ raise ValueError(f"Environment variable {f.name.upper()}: {exc}") from exc
219
+ self._env_fields.add(f.name)
220
+ return self
221
+
222
+ def save(self, config_path: Optional[Path] = None) -> None:
223
+ """Save configuration to file."""
224
+ if config_path is None:
225
+ config_path = self.app_dir / "config.json"
226
+
227
+ # Values that came from environment variables (often secrets) are
228
+ # never written to disk; the file keeps whatever it had before.
229
+ previous: dict = {}
230
+ env_fields = getattr(self, "_env_fields", set())
231
+ if env_fields and config_path.exists():
232
+ try:
233
+ previous = json.loads(config_path.read_text(encoding="utf-8"))
234
+ except (OSError, ValueError):
235
+ previous = {}
236
+
237
+ data = {
238
+ "theme": self.theme,
239
+ "window_width": self.window_width,
240
+ "window_height": self.window_height,
241
+ "cache_expiry_minutes": self.cache_expiry_minutes,
242
+ "default_market": self.default_market,
243
+ "alpha_vantage_key": self.alpha_vantage_key,
244
+ "polygon_key": self.polygon_key,
245
+ "twelvedata_api_key": self.twelvedata_api_key,
246
+ "tiingo_api_key": self.tiingo_api_key,
247
+ "coingecko_api_key": self.coingecko_api_key,
248
+ "telegram_bot_token": self.telegram_bot_token,
249
+ "telegram_chat_id": self.telegram_chat_id,
250
+ "discord_webhook_url": self.discord_webhook_url,
251
+ "slack_webhook_url": self.slack_webhook_url,
252
+ "email_sender": self.email_sender,
253
+ "email_username": self.email_username,
254
+ "email_reports_sender": self.email_reports_sender,
255
+ "email_password": self.email_password,
256
+ "email_receivers": self.email_receivers,
257
+ "email_smtp_server": self.email_smtp_server,
258
+ "email_smtp_port": self.email_smtp_port,
259
+ "email_smtp_ssl": self.email_smtp_ssl,
260
+ "dingtalk_webhook_url": self.dingtalk_webhook_url,
261
+ "dingtalk_secret": self.dingtalk_secret,
262
+ "feishu_webhook_url": self.feishu_webhook_url,
263
+ "feishu_webhook_secret": self.feishu_webhook_secret,
264
+ "wechat_webhook_url": self.wechat_webhook_url,
265
+ "pushover_user_key": self.pushover_user_key,
266
+ "pushover_api_token": self.pushover_api_token,
267
+ "ntfy_url": self.ntfy_url,
268
+ "ntfy_token": self.ntfy_token,
269
+ "gotify_url": self.gotify_url,
270
+ "gotify_token": self.gotify_token,
271
+ "pushplus_token": self.pushplus_token,
272
+ "pushplus_topic": self.pushplus_topic,
273
+ "serverchan3_sendkey": self.serverchan3_sendkey,
274
+ "astrbot_url": self.astrbot_url,
275
+ "astrbot_token": self.astrbot_token,
276
+ "webhook_url": self.webhook_url,
277
+ "webhook_headers": self.webhook_headers,
278
+ "telegram_message_thread_id": self.telegram_message_thread_id,
279
+ "teams_webhook_url": self.teams_webhook_url,
280
+ "google_chat_webhook_url": self.google_chat_webhook_url,
281
+ "matrix_homeserver": self.matrix_homeserver,
282
+ "matrix_access_token": self.matrix_access_token,
283
+ "matrix_room_id": self.matrix_room_id,
284
+ "whatsapp_phone": self.whatsapp_phone,
285
+ "whatsapp_callmebot_apikey": self.whatsapp_callmebot_apikey,
286
+ "twilio_account_sid": self.twilio_account_sid,
287
+ "twilio_auth_token": self.twilio_auth_token,
288
+ "twilio_from": self.twilio_from,
289
+ "twilio_to": self.twilio_to,
290
+ "bark_url": self.bark_url,
291
+ "pushbullet_token": self.pushbullet_token,
292
+ "signal_api_url": self.signal_api_url,
293
+ "signal_number": self.signal_number,
294
+ "signal_recipients": self.signal_recipients,
295
+ "apprise_urls": self.apprise_urls,
296
+ "web_push_vapid_private_key": self.web_push_vapid_private_key,
297
+ "web_push_contact": self.web_push_contact,
298
+ "notification_report_channels": self.notification_report_channels,
299
+ "notification_alert_channels": self.notification_alert_channels,
300
+ "notification_system_error_channels": self.notification_system_error_channels,
301
+ "llm_backend": self.llm_backend,
302
+ "llm_model": self.llm_model,
303
+ "llm_api_base": self.llm_api_base,
304
+ "llm_api_key": self.llm_api_key,
305
+ "llm_timeout_seconds": self.llm_timeout_seconds,
306
+ "llm_max_tokens": self.llm_max_tokens,
307
+ "llm_temperature": self.llm_temperature,
308
+ "llm_api_keys": self.llm_api_keys,
309
+ "llm_fallback_models": self.llm_fallback_models,
310
+ "llm_channels": self.llm_channels,
311
+ "gemini_api_key": self.gemini_api_key,
312
+ "groq_api_key": self.groq_api_key,
313
+ "openrouter_api_key": self.openrouter_api_key,
314
+ "openai_api_key": self.openai_api_key,
315
+ "anthropic_api_key": self.anthropic_api_key,
316
+ "deepseek_api_key": self.deepseek_api_key,
317
+ "mistral_api_key": self.mistral_api_key,
318
+ "cerebras_api_key": self.cerebras_api_key,
319
+ "nvidia_api_key": self.nvidia_api_key,
320
+ "ollama_base_url": self.ollama_base_url,
321
+ "openai_base_url": self.openai_base_url,
322
+ "llm_fast_model": self.llm_fast_model,
323
+ "llm_cache_hours": self.llm_cache_hours,
324
+ "scheduler_workers": self.scheduler_workers,
325
+ "app_base_url": self.app_base_url,
326
+ "support_email": self.support_email,
327
+ "angel_api_key": self.angel_api_key,
328
+ "angel_client_code": self.angel_client_code,
329
+ "angel_pin": self.angel_pin,
330
+ "angel_totp_secret": self.angel_totp_secret,
331
+ "finnhub_api_key": self.finnhub_api_key,
332
+ "reddit_client_id": self.reddit_client_id,
333
+ "reddit_client_secret": self.reddit_client_secret,
334
+ "reddit_user_agent": self.reddit_user_agent,
335
+ "social_reddit_mode": self.social_reddit_mode,
336
+ "social_playwright": self.social_playwright,
337
+ }
338
+ for name in env_fields:
339
+ if name in data:
340
+ if name in previous:
341
+ data[name] = previous[name]
342
+ else:
343
+ data.pop(name)
344
+
345
+ # Keys, tokens and passwords go to disk encrypted.
346
+ from .secret_store import encrypt_fields
347
+
348
+ data = encrypt_fields(data, self.app_dir, getattr(self, "secret_key", None))
349
+ with open(config_path, "w", encoding="utf-8") as f:
350
+ json.dump(data, f, indent=2)
351
+ try:
352
+ config_path.chmod(0o600) # owner-only, like the key file
353
+ except OSError:
354
+ pass
355
+ self._plaintext_secrets = False
356
+
357
+
358
+ def _coerce(annotation, raw: str):
359
+ """Env var text -> the field's declared type (Optional[...] unwrapped)."""
360
+ if typing.get_origin(annotation) is typing.Union:
361
+ annotation = next(a for a in typing.get_args(annotation) if a is not type(None))
362
+ if annotation is bool:
363
+ lowered = raw.lower()
364
+ if lowered in ("1", "true", "yes", "on"):
365
+ return True
366
+ if lowered in ("0", "false", "no", "off"):
367
+ return False
368
+ raise ValueError(f"expected true/false, got {raw!r}")
369
+ if annotation is int:
370
+ return int(raw)
371
+ if annotation is float:
372
+ return float(raw)
373
+ if annotation is list or typing.get_origin(annotation) is list:
374
+ return [part.strip() for part in raw.split(",") if part.strip()]
375
+ if annotation is Path:
376
+ return Path(raw).expanduser()
377
+ return raw
@@ -0,0 +1,69 @@
1
+ """Application constants and enums."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Markets(Enum):
7
+ """Supported markets."""
8
+ US = "us"
9
+ INDIA = "india"
10
+ GLOBAL = "global"
11
+ CRYPTO = "crypto"
12
+ CHINA_A = "china_a"
13
+ HONG_KONG = "hong_kong"
14
+
15
+
16
+ class TimeFrame(Enum):
17
+ """Chart timeframes."""
18
+ MINUTE_1 = "1m"
19
+ MINUTE_5 = "5m"
20
+ MINUTE_15 = "15m"
21
+ MINUTE_30 = "30m"
22
+ HOUR_1 = "1h"
23
+ HOUR_4 = "4h"
24
+ DAY_1 = "1d"
25
+ WEEK_1 = "1wk"
26
+ MONTH_1 = "1mo"
27
+
28
+
29
+
30
+
31
+ # Popular indices by market
32
+ MARKET_INDICES = {
33
+ Markets.US: [
34
+ ("^GSPC", "S&P 500"),
35
+ ("^DJI", "Dow Jones"),
36
+ ("^IXIC", "NASDAQ"),
37
+ ("^RUT", "Russell 2000"),
38
+ ],
39
+ Markets.INDIA: [
40
+ ("^NSEI", "NIFTY 50"),
41
+ ("^BSESN", "SENSEX"),
42
+ ("^NSEBANK", "NIFTY Bank"),
43
+ ("RELIANCE.NS", "Reliance"),
44
+ ],
45
+ Markets.CRYPTO: [
46
+ ("BTC-USD", "Bitcoin"),
47
+ ("ETH-USD", "Ethereum"),
48
+ ("BNB-USD", "Binance Coin"),
49
+ ],
50
+ Markets.CHINA_A: [
51
+ ("000001", "上证指数 (Shanghai Composite)"),
52
+ ("399001", "深证成指 (Shenzhen Component)"),
53
+ ("399006", "创业板指 (ChiNext)"),
54
+ ],
55
+ Markets.HONG_KONG: [
56
+ ("HSI", "恒生指数 (Hang Seng Index)"),
57
+ ("00700", "腾讯控股 (Tencent)"),
58
+ ("09988", "阿里巴巴 (Alibaba)"),
59
+ ],
60
+ }
61
+
62
+ # Default watchlist symbols
63
+ DEFAULT_WATCHLIST = {
64
+ Markets.US: ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA"],
65
+ Markets.INDIA: ["RELIANCE.NS", "TCS.NS", "INFY.NS", "HDFCBANK.NS", "ICICIBANK.NS"],
66
+ Markets.CRYPTO: ["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"],
67
+ Markets.CHINA_A: ["600519", "000001", "000002", "601318"],
68
+ Markets.HONG_KONG: ["00700", "00005", "09988", "03690"],
69
+ }
@@ -0,0 +1,22 @@
1
+ """Where the app keeps its data: ~/.marketmyna, or MARKETMYNA_HOME if set."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def app_home() -> Path:
10
+ raw = os.environ.get("MARKETMYNA_HOME", "").strip()
11
+ return Path(raw).expanduser() if raw else Path.home() / ".marketmyna"
12
+
13
+
14
+ DB_FILENAME = "marketmyna.db"
15
+ LEGACY_DB_FILENAME = "stockpulse.db" # the project's old name
16
+
17
+
18
+ def db_filename(folder: Path) -> str:
19
+ """marketmyna.db, unless only an old stockpulse.db exists in folder."""
20
+ if not (folder / DB_FILENAME).exists() and (folder / LEGACY_DB_FILENAME).exists():
21
+ return LEGACY_DB_FILENAME
22
+ return DB_FILENAME
@@ -0,0 +1,28 @@
1
+ """Small number helpers shared by the data services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Optional
7
+
8
+
9
+ def finite(value) -> Optional[float]:
10
+ """A float, or None for missing, non-numeric, NaN and infinite values."""
11
+ try:
12
+ v = float(value)
13
+ except (TypeError, ValueError):
14
+ return None
15
+ return v if math.isfinite(v) else None
16
+
17
+
18
+ def ratio(a, b) -> Optional[float]:
19
+ """a / b, or None when either is missing or b is zero."""
20
+ return a / b if a is not None and b not in (None, 0) else None
21
+
22
+
23
+ def median(values: list[float]) -> Optional[float]:
24
+ v = sorted(values)
25
+ if not v:
26
+ return None
27
+ mid = len(v) // 2
28
+ return v[mid] if len(v) % 2 else (v[mid - 1] + v[mid]) / 2
@@ -0,0 +1,104 @@
1
+ """Encryption at rest for the secrets kept in config.json.
2
+
3
+ API keys, tokens, passwords and webhook URLs (they embed tokens) are written
4
+ as "enc:v1:<base64>" (AES-256-GCM, random nonce per value) and decrypted in
5
+ memory when the config loads. Older plain-text values still load and are
6
+ encrypted on the next save.
7
+
8
+ The key comes from SECRET_KEY (set it on servers and keep a copy) or, when
9
+ unset, from a random secret.key file created once in the app folder with
10
+ owner-only permissions. Values set through environment variables are never
11
+ written to the file at all (see Config.save).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import secrets
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ PREFIX = "enc:v1:"
28
+
29
+ # Every config field holding something that grants access to an account.
30
+ SECRET_FIELDS = frozenset({
31
+ # AI providers
32
+ "llm_api_key", "llm_api_keys", "gemini_api_key", "groq_api_key", "openrouter_api_key", "openai_api_key",
33
+ "anthropic_api_key", "deepseek_api_key", "mistral_api_key", "cerebras_api_key", "nvidia_api_key",
34
+ # Market data / brokers
35
+ "alpha_vantage_key", "polygon_key", "twelvedata_api_key", "tiingo_api_key", "coingecko_api_key", "finnhub_api_key", "reddit_client_secret", "angel_api_key", "angel_pin", "angel_totp_secret",
36
+ # Notification channels (tokens, passwords, and webhook URLs that embed tokens)
37
+ "telegram_bot_token", "email_password", "discord_webhook_url", "slack_webhook_url", "dingtalk_webhook_url",
38
+ "dingtalk_secret", "feishu_webhook_url", "feishu_webhook_secret", "wechat_webhook_url", "pushover_user_key",
39
+ "pushover_api_token", "ntfy_token", "gotify_token", "pushplus_token", "serverchan3_sendkey", "astrbot_token",
40
+ "webhook_url", "webhook_headers", "teams_webhook_url", "google_chat_webhook_url", "matrix_access_token",
41
+ "whatsapp_callmebot_apikey", "twilio_auth_token", "bark_url", "pushbullet_token", "apprise_urls",
42
+ "web_push_vapid_private_key",
43
+ })
44
+
45
+
46
+ def _key(app_dir: Path, secret_key: Optional[str]) -> bytes:
47
+ secret = secret_key
48
+ if not secret:
49
+ path = Path(app_dir) / "secret.key"
50
+ if not path.exists():
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_text(secrets.token_urlsafe(48), encoding="utf-8")
53
+ path.chmod(0o600)
54
+ secret = path.read_text(encoding="utf-8").strip()
55
+ return hashlib.sha256(b"marketmyna-config-v1:" + secret.encode()).digest()
56
+
57
+
58
+ def is_encrypted(value: Any) -> bool:
59
+ return isinstance(value, str) and value.startswith(PREFIX)
60
+
61
+
62
+ def encrypt(value: Any, app_dir: Path, secret_key: Optional[str]) -> Any:
63
+ """Encrypt one value (str or list); None and already-encrypted pass through."""
64
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
65
+
66
+ if value is None or value == "" or value == [] or is_encrypted(value):
67
+ return value
68
+ nonce = os.urandom(12)
69
+ blob = AESGCM(_key(app_dir, secret_key)).encrypt(nonce, json.dumps(value).encode(), b"config")
70
+ return PREFIX + base64.urlsafe_b64encode(nonce + blob).decode()
71
+
72
+
73
+ def decrypt(value: Any, app_dir: Path, secret_key: Optional[str]) -> Any:
74
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
75
+
76
+ if not is_encrypted(value):
77
+ return value
78
+ raw = base64.urlsafe_b64decode(value[len(PREFIX):].encode())
79
+ return json.loads(AESGCM(_key(app_dir, secret_key)).decrypt(raw[:12], raw[12:], b"config").decode())
80
+
81
+
82
+ def encrypt_fields(data: dict, app_dir: Path, secret_key: Optional[str]) -> dict:
83
+ return {k: encrypt(v, app_dir, secret_key) if k in SECRET_FIELDS else v for k, v in data.items()}
84
+
85
+
86
+ def decrypt_config(config) -> bool:
87
+ """Decrypt secret fields in place. Returns True when a plain-text secret
88
+ was found (the caller saves once to encrypt it)."""
89
+ plain = False
90
+ for name in SECRET_FIELDS:
91
+ value = getattr(config, name, None)
92
+ if value in (None, "", []):
93
+ continue
94
+ if not is_encrypted(value):
95
+ if name not in getattr(config, "_env_fields", set()):
96
+ plain = True
97
+ continue
98
+ try:
99
+ setattr(config, name, decrypt(value, config.app_dir, getattr(config, "secret_key", None)))
100
+ except Exception:
101
+ # Wrong or lost key: drop the value rather than use garbage.
102
+ logger.error("Could not decrypt %s from config.json (SECRET_KEY changed?); it is ignored.", name)
103
+ setattr(config, name, None)
104
+ return plain