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
marketmyna/storage.py ADDED
@@ -0,0 +1,722 @@
1
+ """SQLAlchemy persistence layer for MarketMyna.
2
+
3
+ Single-file models + a small DatabaseManager. Everything lives in one
4
+ SQLite file under ~/.marketmyna/: create_all() makes the tables and
5
+ _add_missing_columns() adds columns that newer models bring.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ from contextlib import contextmanager
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from sqlalchemy import (
18
+ JSON,
19
+ Boolean,
20
+ Column,
21
+ DateTime,
22
+ Float,
23
+ ForeignKey,
24
+ Integer,
25
+ LargeBinary,
26
+ String,
27
+ Text,
28
+ UniqueConstraint,
29
+ create_engine,
30
+ inspect,
31
+ text,
32
+ )
33
+ from sqlalchemy.orm import declarative_base, relationship, sessionmaker
34
+
35
+ from marketmyna.core.home import app_home, db_filename
36
+
37
+ from .core import tenant
38
+
39
+ Base = declarative_base()
40
+
41
+ # Column sizes shared by several tables.
42
+ SYMBOL = 64
43
+ MARKET = 32
44
+ SHORT = 32 # status / kind / type / mode / currency style values
45
+ NAME = 200
46
+ DAY = 10 # YYYY-MM-DD
47
+ TOKEN = 64
48
+
49
+
50
+ def _owner(ondelete: str = "CASCADE") -> Column:
51
+ """The owning account. Nullable while local single-user mode (no
52
+ accounts) shares these tables; becomes NOT NULL in the multi-user
53
+ migration once every query is scoped to the current user."""
54
+ return Column(Integer, ForeignKey("users.id", ondelete=ondelete), nullable=True, index=True)
55
+
56
+
57
+ # ==================== Accounts ====================
58
+
59
+
60
+ class UserRecord(Base):
61
+ """The local owner (single-user build: one row, made on first use)."""
62
+
63
+ __tablename__ = "users"
64
+
65
+ id = Column(Integer, primary_key=True, autoincrement=True)
66
+ email = Column(String(320), unique=True, nullable=False, index=True)
67
+ email_verified_at = Column(DateTime, nullable=True)
68
+ display_name = Column(String(NAME), default="")
69
+ role = Column(String(16), default="admin", nullable=False)
70
+ status = Column(String(16), default="active", nullable=False, index=True)
71
+ timezone = Column(String(64), default="Asia/Kolkata")
72
+ # Follow the time zone of the device the app is opened on (None = on);
73
+ # False keeps the zone picked by hand.
74
+ timezone_auto = Column(Boolean, nullable=True)
75
+ locale = Column(String(16), default="en")
76
+ created_at = Column(DateTime, default=datetime.now)
77
+ # The area ("trader" | "investor") the menus and pages follow (None = both).
78
+ last_area = Column(String(16), nullable=True)
79
+
80
+
81
+ class UserApiKeyRecord(Base):
82
+ """An AI provider key (Settings -> AI keys), AES-GCM encrypted with the
83
+ master key. Only the last 4 characters are shown. Several may be kept (any
84
+ providers, also two of one); AI tries them oldest first and falls back to
85
+ the next on any failure (ai_access)."""
86
+
87
+ __tablename__ = "user_api_keys"
88
+
89
+ id = Column(Integer, primary_key=True, autoincrement=True)
90
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
91
+ provider = Column(String(SHORT), nullable=False) # gemini | groq | openai | anthropic | ...
92
+ key_ciphertext = Column(LargeBinary, nullable=False)
93
+ key_last4 = Column(String(8), default="")
94
+ model = Column(String(128), nullable=True) # litellm id picked for the key; null = provider default
95
+ free = Column(Boolean, nullable=True) # key has only free access (None = unknown)
96
+ note = Column(String(255), nullable=True) # what was detected, shown to the user
97
+ created_at = Column(DateTime, default=datetime.now)
98
+ last_used_at = Column(DateTime, nullable=True)
99
+
100
+
101
+ # ==================== Portfolio domain ====================
102
+
103
+
104
+ class PortfolioRecord(Base):
105
+ __tablename__ = "portfolios"
106
+ __table_args__ = (UniqueConstraint("user_id", "name", name="uq_portfolio_user_name"),)
107
+
108
+ id = Column(Integer, primary_key=True, autoincrement=True)
109
+ user_id = _owner()
110
+ name = Column(String(NAME), nullable=False, index=True)
111
+ cash_balance = Column(Float, default=0.0)
112
+ currency = Column(String(8), default="USD")
113
+ # "stocks" | "funds" (mutual funds); None on old rows = inferred from the holdings.
114
+ # Free has one of each (marketmyna/services/free_picks.py).
115
+ kind = Column(String(16), nullable=True)
116
+ created_at = Column(DateTime, default=datetime.now)
117
+
118
+ holdings = relationship(
119
+ "HoldingRecord", back_populates="portfolio", cascade="all, delete-orphan"
120
+ )
121
+ transactions = relationship(
122
+ "TransactionRecord",
123
+ back_populates="portfolio",
124
+ cascade="all, delete-orphan",
125
+ order_by="TransactionRecord.id",
126
+ )
127
+
128
+
129
+ class FundDipRuleRecord(Base):
130
+ """A user's own dip-alert levels for mutual funds (marketmyna/services/mf_lumpsum.py):
131
+ % below the 52-week high, e.g. [5, 7.5, 10]. fund_code None = the
132
+ portfolio's default for all its funds; a code = an override for that fund."""
133
+
134
+ __tablename__ = "fund_dip_rules"
135
+ __table_args__ = (UniqueConstraint("portfolio_id", "fund_code", name="uq_dip_rule_portfolio_fund"),)
136
+
137
+ id = Column(Integer, primary_key=True, autoincrement=True)
138
+ user_id = _owner()
139
+ portfolio_id = Column(Integer, ForeignKey("portfolios.id", ondelete="CASCADE"), nullable=False, index=True)
140
+ fund_code = Column(String(SYMBOL), nullable=True)
141
+ levels = Column(JSON, default=list)
142
+ updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
143
+
144
+
145
+ class PortfolioReviewRuleRecord(Base):
146
+ """A user's own limits for the portfolio review (marketmyna/portfolio/review.py):
147
+ max % per holding / sector / cash, loss level to flag, minimum holdings."""
148
+
149
+ __tablename__ = "portfolio_review_rules"
150
+
151
+ id = Column(Integer, primary_key=True, autoincrement=True)
152
+ user_id = _owner()
153
+ portfolio_id = Column(Integer, ForeignKey("portfolios.id", ondelete="CASCADE"), nullable=False, unique=True)
154
+ limits = Column(JSON, default=dict)
155
+ updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
156
+
157
+
158
+ class HoldingRecord(Base):
159
+ __tablename__ = "holdings"
160
+ __table_args__ = (UniqueConstraint("portfolio_id", "symbol", name="uq_holding_portfolio_symbol"),)
161
+
162
+ id = Column(Integer, primary_key=True, autoincrement=True)
163
+ portfolio_id = Column(Integer, ForeignKey("portfolios.id", ondelete="CASCADE"), nullable=False, index=True)
164
+ symbol = Column(String(SYMBOL), nullable=False)
165
+ quantity = Column(Float, default=0.0)
166
+ avg_cost = Column(Float, default=0.0)
167
+ current_price = Column(Float, default=0.0)
168
+ market = Column(String(MARKET), default="US")
169
+
170
+ portfolio = relationship("PortfolioRecord", back_populates="holdings")
171
+
172
+
173
+ class TransactionRecord(Base):
174
+ __tablename__ = "transactions"
175
+
176
+ id = Column(Integer, primary_key=True, autoincrement=True)
177
+ portfolio_id = Column(Integer, ForeignKey("portfolios.id", ondelete="CASCADE"), nullable=False, index=True)
178
+ symbol = Column(String(SYMBOL), nullable=False)
179
+ transaction_type = Column(String(SHORT), nullable=False)
180
+ quantity = Column(Float)
181
+ price = Column(Float)
182
+ date = Column(DateTime)
183
+ fees = Column(Float, default=0.0)
184
+ notes = Column(Text, default="")
185
+ market = Column(String(MARKET), nullable=True)
186
+
187
+ portfolio = relationship("PortfolioRecord", back_populates="transactions")
188
+
189
+
190
+ # ==================== Alerts domain ====================
191
+
192
+
193
+ # ==================== Scheduler domain ====================
194
+
195
+
196
+ class ScheduleConfigRecord(Base):
197
+ """The runtime scheduler's persisted config - one row per user in
198
+ accounts mode, a single row otherwise - which symbols to analyze, how often, and the outcome of the
199
+ last run. No cron-expression parser: either "every N minutes" or
200
+ "once daily at HH:MM", which covers the realistic personal-tool cases
201
+ without the complexity of a real cron grammar."""
202
+
203
+ __tablename__ = "schedule_config"
204
+
205
+ id = Column(Integer, primary_key=True)
206
+ user_id = _owner()
207
+ enabled = Column(Boolean, default=False)
208
+ interval_minutes = Column(Integer, nullable=True) # e.g. 60 -> every hour
209
+ time_of_day = Column(String(16), nullable=True) # e.g. "09:30" -> once daily
210
+ symbols = Column(JSON, default=list)
211
+ market = Column(String(MARKET), default="us")
212
+ mode = Column(String(SHORT), default="standard")
213
+ last_run_at = Column(DateTime, nullable=True)
214
+ last_run_status = Column(String(SHORT), nullable=True)
215
+ last_run_summary = Column(Text, nullable=True)
216
+ # Report content/delivery preferences (see marketmyna/services/report.py)
217
+ report_language = Column(String(16), nullable=True)
218
+ report_sections = Column(JSON, nullable=True)
219
+ stock_sections = Column(JSON, nullable=True)
220
+ channel_languages = Column(JSON, nullable=True)
221
+ mf_codes = Column(JSON, nullable=True)
222
+ # Smart messaging: short "top N" message + link to the full report
223
+ smart_enabled = Column(Boolean, nullable=True)
224
+ smart_top_n = Column(Integer, nullable=True)
225
+ smart_ai_top = Column(Integer, nullable=True)
226
+ smart_email_full = Column(Boolean, nullable=True)
227
+ # Smart messaging's report file: "pdf" | "xlsx" | "none" (None = pdf), and the
228
+ # channels it goes to (None = email and telegram). marketmyna/services/smart_file.py.
229
+ smart_file = Column(String(8), nullable=True)
230
+ smart_file_channels = Column(JSON, nullable=True)
231
+ report_base_url = Column(Text, nullable=True)
232
+ # What a run covers: "watchlist", "portfolio", "custom" (the symbols list)
233
+ sources = Column(JSON, nullable=True)
234
+ watchlist_ids = Column(JSON, nullable=True) # empty = every watchlist
235
+ portfolio_names = Column(JSON, nullable=True) # empty = every portfolio
236
+ # Stocks a run covers (worked out when saved and after each run): what the
237
+ # schedule costs in its time slot (marketmyna/services/schedule_slots.py).
238
+ stock_count = Column(Integer, nullable=True)
239
+ # "trader" / "investor": the login area the report was set up in. A run
240
+ # includes only that area's sections (report.SECTION_AREAS).
241
+ area = Column(String(16), nullable=True)
242
+
243
+
244
+ # ==================== Watchlist domain ====================
245
+
246
+
247
+ class WatchlistRecord(Base):
248
+ """A named list of instruments to track - no quantities or money, just
249
+ what to keep an eye on (holdings live in the portfolio domain)."""
250
+
251
+ __tablename__ = "watchlists"
252
+ __table_args__ = (UniqueConstraint("user_id", "name", name="uq_watchlist_user_name"),)
253
+
254
+ id = Column(Integer, primary_key=True, autoincrement=True)
255
+ user_id = _owner()
256
+ name = Column(String(NAME), nullable=False)
257
+ created_at = Column(DateTime, default=datetime.now)
258
+
259
+ items = relationship(
260
+ "WatchlistItemRecord",
261
+ back_populates="watchlist",
262
+ cascade="all, delete-orphan",
263
+ order_by="WatchlistItemRecord.id",
264
+ )
265
+
266
+
267
+ class WatchlistItemRecord(Base):
268
+ __tablename__ = "watchlist_items"
269
+ __table_args__ = (
270
+ UniqueConstraint("watchlist_id", "market", "type", "symbol", name="uq_watchlist_item"),
271
+ )
272
+
273
+ id = Column(Integer, primary_key=True, autoincrement=True)
274
+ watchlist_id = Column(Integer, ForeignKey("watchlists.id", ondelete="CASCADE"), nullable=False, index=True)
275
+ symbol = Column(String(SYMBOL), nullable=False)
276
+ market = Column(String(MARKET), nullable=False)
277
+ type = Column(String(16), nullable=False, default="stock") # stock | etf | mf | crypto
278
+ name = Column(String(500), default="")
279
+ note = Column(Text, default="")
280
+ added_at = Column(DateTime, default=datetime.now)
281
+
282
+ watchlist = relationship("WatchlistRecord", back_populates="items")
283
+
284
+
285
+ # ==================== Web push domain ====================
286
+
287
+
288
+ class WebPushSubscriptionRecord(Base):
289
+ """A browser/device that allowed MarketMyna push notifications (the
290
+ PushSubscription a service worker returns)."""
291
+
292
+ __tablename__ = "web_push_subscriptions"
293
+
294
+ id = Column(Integer, primary_key=True, autoincrement=True)
295
+ user_id = _owner()
296
+ endpoint = Column(String(700), unique=True, nullable=False)
297
+ p256dh = Column(String(255), nullable=False)
298
+ auth = Column(String(255), nullable=False)
299
+ label = Column(String(NAME), default="")
300
+ created_at = Column(DateTime, default=datetime.now)
301
+ last_success_at = Column(DateTime, nullable=True)
302
+
303
+
304
+ # ==================== LLM cache / usage ====================
305
+
306
+
307
+ class LLMCacheRecord(Base):
308
+ """A recent AI answer, reused for an identical request so free-tier
309
+ quotas aren't spent twice on the same prompt."""
310
+
311
+ __tablename__ = "llm_cache"
312
+
313
+ key = Column(String(TOKEN), primary_key=True) # sha256 of the request
314
+ text = Column(Text, nullable=False)
315
+ model = Column(String(255), default="")
316
+ created_at = Column(DateTime, default=datetime.now, index=True)
317
+
318
+
319
+ class LLMUsageRecord(Base):
320
+ """Per-day, per-model counters for the Settings usage panel."""
321
+
322
+ __tablename__ = "llm_usage"
323
+ __table_args__ = (UniqueConstraint("day", "route", name="uq_llm_usage_day_route"),)
324
+
325
+ id = Column(Integer, primary_key=True, autoincrement=True)
326
+ day = Column(String(DAY), nullable=False, index=True) # YYYY-MM-DD
327
+ route = Column(String(255), nullable=False) # e.g. "fast:groq/llama-3.3-70b-versatile"
328
+ calls = Column(Integer, default=0)
329
+ failures = Column(Integer, default=0)
330
+ cache_hits = Column(Integer, default=0)
331
+ prompt_tokens = Column(Integer, default=0)
332
+ completion_tokens = Column(Integer, default=0)
333
+ # The latest failure that day: retired / quota / key / timeout / outage / other.
334
+ last_error_kind = Column(String(32), nullable=True)
335
+ last_error = Column(String(300), nullable=True)
336
+
337
+
338
+ # ==================== Social sentiment ====================
339
+
340
+
341
+ class SocialSentimentRecord(Base):
342
+ """One day's social-sentiment reading per stock (latest fetch of the
343
+ day wins) - the history behind the Analyze page's trend line. Shared
344
+ market data, so no owner."""
345
+
346
+ __tablename__ = "social_sentiment"
347
+ __table_args__ = (UniqueConstraint("symbol", "market", "day", name="uq_social_sentiment_symbol_day"),)
348
+
349
+ id = Column(Integer, primary_key=True, autoincrement=True)
350
+ symbol = Column(String(SYMBOL), nullable=False, index=True)
351
+ market = Column(String(MARKET), nullable=False)
352
+ day = Column(String(DAY), nullable=False, index=True) # YYYY-MM-DD
353
+ score = Column(Float, nullable=False) # -100 (bearish) .. +100 (bullish)
354
+ posts = Column(Integer, default=0)
355
+ bullish = Column(Integer, default=0)
356
+ bearish = Column(Integer, default=0)
357
+ neutral = Column(Integer, default=0)
358
+ updated_at = Column(DateTime, default=datetime.now)
359
+
360
+
361
+ # ==================== Saved reports ====================
362
+
363
+
364
+ class ReportRunRecord(Base):
365
+ """One scheduled run's full per-stock table, opened through the link in
366
+ a smart message. The token is the unguessable part of that link."""
367
+
368
+ __tablename__ = "report_runs"
369
+
370
+ id = Column(Integer, primary_key=True, autoincrement=True)
371
+ user_id = _owner()
372
+ token = Column(String(TOKEN), unique=True, nullable=False, index=True)
373
+ created_at = Column(DateTime, default=datetime.now, index=True)
374
+ title = Column(String(500), default="")
375
+ stock_count = Column(Integer, default=0)
376
+ rows = Column(JSON, default=list)
377
+ meta = Column(JSON, default=dict)
378
+
379
+
380
+ # ==================== Trade section ====================
381
+
382
+
383
+ class PriceAlertRecord(Base):
384
+ """A trader's price / indicator alert, checked by marketmyna/trading/alerts.py's
385
+ AlertMonitor during market hours."""
386
+
387
+ __tablename__ = "price_alerts"
388
+
389
+ id = Column(Integer, primary_key=True, autoincrement=True)
390
+ user_id = _owner()
391
+ symbol = Column(String(SYMBOL), nullable=False, index=True)
392
+ market = Column(String(MARKET), nullable=False)
393
+ kind = Column(String(SHORT), nullable=False) # see marketmyna/trading/alerts.py KINDS
394
+ value = Column(Float, nullable=False)
395
+ note = Column(Text, default="")
396
+ status = Column(String(16), default="active", index=True) # active | paused | triggered | expired
397
+ repeat = Column(Boolean, default=False) # re-arm once the condition clears
398
+ armed = Column(Boolean, default=True)
399
+ cooldown_minutes = Column(Integer, default=30)
400
+ expires_at = Column(DateTime, nullable=True)
401
+ created_at = Column(DateTime, default=datetime.now)
402
+ last_checked_at = Column(DateTime, nullable=True)
403
+ last_value = Column(Float, nullable=True) # what the condition last measured
404
+ last_triggered_at = Column(DateTime, nullable=True)
405
+ trigger_count = Column(Integer, default=0)
406
+
407
+
408
+ class PriceAlertEventRecord(Base):
409
+ """One firing of a price alert (the Alerts tab's history)."""
410
+
411
+ __tablename__ = "price_alert_events"
412
+
413
+ id = Column(Integer, primary_key=True, autoincrement=True)
414
+ user_id = _owner()
415
+ alert_id = Column(Integer, index=True)
416
+ symbol = Column(String(SYMBOL), nullable=False)
417
+ market = Column(String(MARKET), nullable=False)
418
+ kind = Column(String(SHORT), nullable=False)
419
+ message = Column(Text, default="")
420
+ price = Column(Float, nullable=True)
421
+ delivery = Column(JSON, default=dict)
422
+ created_at = Column(DateTime, default=datetime.now, index=True)
423
+
424
+
425
+ class SavedScanRecord(Base):
426
+ """A scanner setup the trader re-runs; with notify on, AlertMonitor runs
427
+ it every interval while its market is open and sends new matches."""
428
+
429
+ __tablename__ = "saved_scans"
430
+
431
+ id = Column(Integer, primary_key=True, autoincrement=True)
432
+ user_id = _owner()
433
+ name = Column(String(NAME), nullable=False)
434
+ universe = Column(JSON, default=dict) # see marketmyna/trading/scanner.py resolve_universe
435
+ scans = Column(JSON, default=list)
436
+ params = Column(JSON, default=dict)
437
+ match = Column(String(8), default="any") # any | all
438
+ notify = Column(Boolean, default=False)
439
+ interval_minutes = Column(Integer, default=15)
440
+ created_at = Column(DateTime, default=datetime.now)
441
+ last_run_at = Column(DateTime, nullable=True)
442
+ last_count = Column(Integer, nullable=True)
443
+ notified_day = Column(String(DAY), nullable=True) # YYYY-MM-DD the list below belongs to
444
+ notified_symbols = Column(JSON, default=list) # already sent today, so only new matches go out
445
+
446
+
447
+ class TradeRiskSettingsRecord(Base):
448
+ """Singleton (id=1): the trader's daily risk rules (marketmyna/trading/risk.py)."""
449
+
450
+ __tablename__ = "trade_risk_settings"
451
+
452
+ id = Column(Integer, primary_key=True)
453
+ user_id = _owner()
454
+ market = Column(String(MARKET), default="india") # whose trades / currency the rules cover
455
+ capital = Column(Float, default=100000.0)
456
+ daily_loss_limit_pct = Column(Float, default=2.0)
457
+ max_open_risk_pct = Column(Float, default=5.0)
458
+ max_trades_per_day = Column(Integer, default=5)
459
+ notify = Column(Boolean, default=True)
460
+ notified_day = Column(String(DAY), nullable=True)
461
+
462
+
463
+ class CalendarSettingsRecord(Base):
464
+ """Singleton (id=1): event-calendar reminder settings (marketmyna/services/calendar.py)."""
465
+
466
+ __tablename__ = "calendar_settings"
467
+
468
+ id = Column(Integer, primary_key=True)
469
+ user_id = _owner()
470
+ notify = Column(Boolean, default=True)
471
+ days_before = Column(Integer, default=1) # remind about events within this many days
472
+ notify_hour = Column(Integer, default=8) # local hour (in `timezone`) the daily digest goes out
473
+ timezone = Column(String(64), default="Asia/Kolkata")
474
+ notified_day = Column(String(DAY), nullable=True) # YYYY-MM-DD the digest was last handled
475
+ hidden_events = Column(Text, nullable=True) # comma-separated event type keys the user turned off
476
+ results_sent = Column(Text, nullable=True) # "YYYY-MM-DD:key,key" release results already sent that day
477
+ # "My events list": which lists feed the calendar ("w:<id>" / "p:<id>"; null = every list)
478
+ sources = Column(JSON, nullable=True)
479
+ extra_symbols = Column(JSON, nullable=True) # [{symbol, market}] followed just for events
480
+
481
+
482
+ class EventAlertRecord(Base):
483
+ """A named event alert: its own stocks, event types and send time; one message per run."""
484
+
485
+ __tablename__ = "event_alerts"
486
+
487
+ id = Column(Integer, primary_key=True)
488
+ user_id = _owner()
489
+ name = Column(String(NAME), nullable=False)
490
+ enabled = Column(Boolean, default=True)
491
+ sources = Column(JSON, nullable=True) # like CalendarSettingsRecord.sources; [] = only `symbols`
492
+ symbols = Column(JSON, nullable=True) # [{symbol, market}] added to this alert
493
+ event_types = Column(JSON, nullable=True) # event type keys (marketmyna/services/calendar.py); null = all
494
+ days_before = Column(Integer, default=1)
495
+ notify_hour = Column(Integer, default=8)
496
+ notified_day = Column(String(DAY), nullable=True)
497
+
498
+
499
+ class CustomEventRecord(Base):
500
+ """A user's own dated item (SIP date, IPO listing, AGM...), optionally repeating."""
501
+
502
+ __tablename__ = "custom_events"
503
+
504
+ id = Column(Integer, primary_key=True)
505
+ user_id = _owner()
506
+ title = Column(String(NAME), nullable=False)
507
+ date = Column(String(DAY), nullable=False) # first (or only) occurrence, YYYY-MM-DD
508
+ repeat = Column(String(SHORT), default="none") # none | monthly | yearly
509
+ note = Column(Text, nullable=True)
510
+
511
+
512
+ class ThesisRecord(Base):
513
+ """Why the user owns (or watches) a stock: falsifiable statement, pillars,
514
+ risks, exit / target levels and a dated log (marketmyna/services/thesis.py)."""
515
+
516
+ __tablename__ = "theses"
517
+ __table_args__ = (UniqueConstraint("user_id", "symbol", "market", name="uq_thesis_user_symbol"),)
518
+
519
+ id = Column(Integer, primary_key=True)
520
+ user_id = _owner()
521
+ symbol = Column(String(SYMBOL), nullable=False)
522
+ market = Column(String(MARKET), nullable=False)
523
+ statement = Column(Text, nullable=False)
524
+ pillars = Column(JSON, nullable=True) # [{text, status: on_track | watch | broken}]
525
+ risks = Column(JSON, nullable=True) # [text]
526
+ exit_below = Column(Float, nullable=True) # price that would break the thesis
527
+ exit_note = Column(Text, nullable=True) # what else would make you exit
528
+ target = Column(Float, nullable=True) # what it is worth if the thesis plays out
529
+ conviction = Column(String(SHORT), default="medium") # high | medium | low
530
+ log = Column(JSON, nullable=True) # [{date, note, impact: strengthens | weakens | neutral, conviction}]
531
+ created_at = Column(DateTime, default=datetime.now)
532
+ reviewed_at = Column(String(DAY), nullable=True) # YYYY-MM-DD of the last review
533
+ alerted = Column(JSON, nullable=True) # {"exit": day, "target": day, "review": day} messages sent
534
+
535
+
536
+ class TradeRecord(Base):
537
+ """A trade journal entry. Open while exit_price is empty."""
538
+
539
+ __tablename__ = "trades"
540
+
541
+ id = Column(Integer, primary_key=True, autoincrement=True)
542
+ user_id = _owner()
543
+ symbol = Column(String(SYMBOL), nullable=False, index=True)
544
+ market = Column(String(MARKET), nullable=False)
545
+ side = Column(String(8), default="long") # long | short
546
+ quantity = Column(Float, nullable=False)
547
+ entry_price = Column(Float, nullable=False)
548
+ entry_at = Column(DateTime, default=datetime.now)
549
+ exit_price = Column(Float, nullable=True)
550
+ exit_at = Column(DateTime, nullable=True)
551
+ stop = Column(Float, nullable=True)
552
+ target = Column(Float, nullable=True)
553
+ fees = Column(Float, default=0.0)
554
+ fees_auto = Column(Boolean, nullable=True) # fees computed by marketmyna/trading/charges.py
555
+ product = Column(String(16), nullable=True) # india: intraday | delivery | futures | options
556
+ setup = Column(String(NAME), default="") # e.g. "breakout", "pullback"
557
+ notes = Column(Text, default="")
558
+ created_at = Column(DateTime, default=datetime.now)
559
+
560
+
561
+ # ==================== Feedback ====================
562
+
563
+
564
+ # ==================== Usage analytics ====================
565
+
566
+
567
+ # ==================== Telegram subscribers ====================
568
+
569
+
570
+ # ==================== Security monitoring ====================
571
+
572
+
573
+ # ==================== Maintenance notices ====================
574
+
575
+
576
+ # ==================== Engine / session management ====================
577
+
578
+
579
+ # Accounts mode: these tables are filtered to the signed-in user on every
580
+ # query (marketmyna/core/tenant.py). Feedback and usage only get the author's id;
581
+ # the admin reads them across users. Each user has their own scheduled
582
+ # report (schedule_config).
583
+ tenant.configure(
584
+ owned=(
585
+ PortfolioRecord, WatchlistRecord, WebPushSubscriptionRecord, ReportRunRecord,
586
+ PriceAlertRecord, PriceAlertEventRecord, SavedScanRecord, TradeRiskSettingsRecord,
587
+ CalendarSettingsRecord, TradeRecord, ScheduleConfigRecord,
588
+ FundDipRuleRecord, PortfolioReviewRuleRecord, EventAlertRecord, CustomEventRecord, ThesisRecord,
589
+ ),
590
+ )
591
+
592
+ # Every table, by whose rows it holds. tests/test_tenant_isolation.py checks
593
+ # that each table is listed exactly once (here or in tenant.configure above),
594
+ # so a new table can't skip the decision.
595
+ #
596
+ # One user's rows, filtered by an explicit `user_id == ...` in the one service
597
+ # that reads them (accounts, AI access) - not by the automatic scope,
598
+ # because some code works before or across a user scope.
599
+ SELF_FILTERED_TABLES = frozenset({
600
+ "users", "user_api_keys",
601
+ })
602
+ # No user_id of their own: reached only through their owned parent row.
603
+ CHILD_TABLES = {"holdings": "portfolios", "transactions": "portfolios", "watchlist_items": "watchlists"}
604
+ # Not tied to a user: AI usage counters and the shared market-data and
605
+ # AI answer caches.
606
+ PLATFORM_TABLES = frozenset({"llm_cache", "llm_usage", "social_sentiment"})
607
+
608
+
609
+ def resolve_db_path(
610
+ config=None, override_dir: Optional[Path] = None, filename: Optional[str] = None
611
+ ) -> Path:
612
+ """Pick the db file: an explicit Config.db_path wins, then an override
613
+ directory (e.g. a manager's legacy data_dir, or a test's tmp_path), then
614
+ the shared default under ~/.marketmyna/."""
615
+ if config is not None and getattr(config, "db_path", None):
616
+ return Path(config.db_path)
617
+ folder = Path(override_dir) if override_dir is not None else app_home()
618
+ return folder / (filename or db_filename(folder))
619
+
620
+
621
+ def has_app_db(config=None) -> bool:
622
+ """Whether this machine has the app's SQLite file (a fresh GitHub runner
623
+ does not)."""
624
+ return resolve_db_path(config=config).exists()
625
+
626
+
627
+ def get_db_url(config=None, override_dir: Optional[Path] = None, filename: Optional[str] = None) -> str:
628
+ path = resolve_db_path(config=config, override_dir=override_dir, filename=filename)
629
+ path.parent.mkdir(parents=True, exist_ok=True)
630
+ return f"sqlite:///{path.absolute()}"
631
+
632
+
633
+ def _make_private(db_url: str) -> None:
634
+ """The SQLite file holds sessions and encrypted keys: owner-only access."""
635
+ import os
636
+
637
+ path = Path(db_url.split("///", 1)[-1])
638
+ for p in (path, Path(f"{path}-wal"), Path(f"{path}-shm")):
639
+ try:
640
+ if p.exists():
641
+ os.chmod(p, 0o600)
642
+ except OSError:
643
+ pass
644
+
645
+
646
+ # Called with every new engine (marketmyna/security/dbwatch.py counts queries and
647
+ # notes slow / failing ones for the admin's Security page).
648
+ ENGINE_HOOKS: list = []
649
+
650
+
651
+ def _finite(value):
652
+ """NaN / infinity -> None, all the way down (a stock without a price
653
+ carries NaN, which is not valid JSON)."""
654
+ if isinstance(value, float):
655
+ return value if math.isfinite(value) else None
656
+ if isinstance(value, dict):
657
+ return {k: _finite(v) for k, v in value.items()}
658
+ if isinstance(value, (list, tuple)):
659
+ return [_finite(v) for v in value]
660
+ return value
661
+
662
+
663
+ def json_dumps(value) -> str:
664
+ """Serializer for every JSON column: always valid JSON."""
665
+ return json.dumps(_finite(value))
666
+
667
+
668
+ class DatabaseManager:
669
+ """One instance per distinct db_url, cached so callers sharing a db file
670
+ share an engine/connection pool instead of each opening their own."""
671
+
672
+ _instances: dict[str, "DatabaseManager"] = {}
673
+
674
+ def __init__(self, db_url: str):
675
+ self.db_url = db_url
676
+ if not db_url.startswith("sqlite"):
677
+ raise ValueError(f"MarketMyna stores its data in SQLite; got {db_url!r}")
678
+ self.engine = create_engine(
679
+ db_url, pool_pre_ping=True, connect_args={"check_same_thread": False}, json_serializer=json_dumps
680
+ )
681
+ with self.engine.connect() as conn:
682
+ conn.execute(text("PRAGMA journal_mode=WAL"))
683
+ _make_private(db_url)
684
+ for hook in ENGINE_HOOKS:
685
+ hook(self.engine)
686
+ self.SessionLocal = sessionmaker(bind=self.engine, expire_on_commit=False)
687
+ Base.metadata.create_all(self.engine)
688
+ self._add_missing_columns()
689
+
690
+ def _add_missing_columns(self) -> None:
691
+ """create_all() never alters existing tables, so columns added to a
692
+ model later are appended here (nullable, no data rewrite) - enough
693
+ for this app's additive schema changes without a migration tool."""
694
+ inspector = inspect(self.engine)
695
+ with self.engine.begin() as conn:
696
+ for table in Base.metadata.sorted_tables:
697
+ if not inspector.has_table(table.name):
698
+ continue
699
+ existing = {c["name"] for c in inspector.get_columns(table.name)}
700
+ for column in table.columns:
701
+ if column.name in existing:
702
+ continue
703
+ col_type = column.type.compile(dialect=self.engine.dialect)
704
+ conn.execute(text(f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {col_type}'))
705
+
706
+ @classmethod
707
+ def instance(cls, db_url: str) -> "DatabaseManager":
708
+ if db_url not in cls._instances:
709
+ cls._instances[db_url] = cls(db_url)
710
+ return cls._instances[db_url]
711
+
712
+ @contextmanager
713
+ def get_session(self):
714
+ session = self.SessionLocal()
715
+ try:
716
+ yield session
717
+ session.commit()
718
+ except Exception:
719
+ session.rollback()
720
+ raise
721
+ finally:
722
+ session.close()