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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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()
|