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