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,394 @@
|
|
|
1
|
+
"""Updates (Settings), against github.com/pryndor/MarketMyna.
|
|
2
|
+
|
|
3
|
+
Checking:
|
|
4
|
+
- releases: the running version against the latest release (every install);
|
|
5
|
+
- the main branch: a git checkout also compares its commit with GitHub's
|
|
6
|
+
latest, so new code shows up before it is released.
|
|
7
|
+
A background thread checks on its own every few minutes (5 min, 1 h, 2 h or
|
|
8
|
+
off, saved in ~/.marketmyna/updates.json), so the sidebar can say an update
|
|
9
|
+
is waiting without anyone pressing a button.
|
|
10
|
+
|
|
11
|
+
Updating ("Update now"): a git checkout fetches main and moves to it (a
|
|
12
|
+
backup branch keeps the old commit; local edits stop it), a pipx / pip install
|
|
13
|
+
upgrades the package; both then install the requirements and restart the
|
|
14
|
+
app. A release zip is updated by hand (download the new zip).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
|
|
34
|
+
from marketmyna import __version__
|
|
35
|
+
from marketmyna.api.middlewares.auth import require_admin
|
|
36
|
+
from marketmyna.core.home import app_home
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
router = APIRouter(prefix="/system/updates", dependencies=[Depends(require_admin)])
|
|
41
|
+
|
|
42
|
+
REPO = "pryndor/MarketMyna"
|
|
43
|
+
BRANCH = "main"
|
|
44
|
+
REPO_GIT = f"https://github.com/{REPO}.git"
|
|
45
|
+
RELEASES_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
|
|
46
|
+
COMMIT_URL = f"https://api.github.com/repos/{REPO}/commits/{BRANCH}"
|
|
47
|
+
RELEASES_PAGE = f"https://github.com/{REPO}/releases"
|
|
48
|
+
REPO_PAGE = f"https://github.com/{REPO}"
|
|
49
|
+
# GitHub allows 60 unauthenticated calls an hour; a check is 2 calls, so even
|
|
50
|
+
# every 5 minutes uses under half. Answers younger than this are reused.
|
|
51
|
+
CACHE_SECONDS = 240
|
|
52
|
+
_cache: dict = {"at": 0.0, "release": None, "commit_at": 0.0, "commit": None}
|
|
53
|
+
|
|
54
|
+
# How often the background thread checks, in minutes (0 = off).
|
|
55
|
+
INTERVALS = (0, 5, 60, 120)
|
|
56
|
+
DEFAULT_INTERVAL = 5
|
|
57
|
+
|
|
58
|
+
ROOT = Path(__file__).resolve().parents[4]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_version(text: str) -> tuple[int, ...]:
|
|
62
|
+
"""'v0.10.2' -> (0, 10, 2); anything after the numbers (rc1, -beta) is ignored."""
|
|
63
|
+
match = re.match(r"\s*v?(\d+(?:\.\d+)*)", text or "")
|
|
64
|
+
return tuple(int(part) for part in match.group(1).split(".")) if match else ()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_newer(latest: str, current: str) -> bool:
|
|
68
|
+
a, b = parse_version(latest), parse_version(current)
|
|
69
|
+
width = max(len(a), len(b))
|
|
70
|
+
return (a + (0,) * (width - len(a))) > (b + (0,) * (width - len(b)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def install_kind() -> str:
|
|
74
|
+
"""'source': a git checkout; 'zip': a release zip run with start.py; 'package': pipx / pip."""
|
|
75
|
+
if not (ROOT / "start.py").exists():
|
|
76
|
+
return "package"
|
|
77
|
+
return "source" if (ROOT / ".git").exists() else "zip"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
UPDATE_STEPS = {
|
|
81
|
+
"source": ["git pull", "pip install -r requirements-web.txt", "python start.py --rebuild"],
|
|
82
|
+
"zip": ["Download the new zip from the releases page, unpack it, then run: pip install -r requirements-web.txt && python start.py"],
|
|
83
|
+
"package": ["pipx upgrade marketmyna"],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ settings
|
|
88
|
+
|
|
89
|
+
def _settings_file() -> Path:
|
|
90
|
+
return app_home() / "updates.json"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_settings() -> dict:
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(_settings_file().read_text(encoding="utf-8"))
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
data = {}
|
|
98
|
+
interval = data.get("interval_minutes", DEFAULT_INTERVAL)
|
|
99
|
+
return {"interval_minutes": interval if interval in INTERVALS else DEFAULT_INTERVAL}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def save_settings(settings: dict) -> None:
|
|
103
|
+
path = _settings_file()
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(json.dumps(settings), encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ GitHub
|
|
109
|
+
|
|
110
|
+
def _github(url: str):
|
|
111
|
+
response = httpx.get(
|
|
112
|
+
url,
|
|
113
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": f"MarketMyna/{__version__}"},
|
|
114
|
+
timeout=10,
|
|
115
|
+
follow_redirects=True,
|
|
116
|
+
)
|
|
117
|
+
if response.status_code == 404:
|
|
118
|
+
return None
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
return response.json()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _latest_release(force: bool = False) -> dict | None:
|
|
124
|
+
"""The newest published release, or None when the repo has none yet."""
|
|
125
|
+
now = time.time()
|
|
126
|
+
if not force and _cache["at"] and now - _cache["at"] < CACHE_SECONDS:
|
|
127
|
+
return _cache["release"]
|
|
128
|
+
data = _github(RELEASES_URL)
|
|
129
|
+
release = None
|
|
130
|
+
if data:
|
|
131
|
+
release = {
|
|
132
|
+
"version": str(data.get("tag_name") or "").lstrip("v"),
|
|
133
|
+
"name": data.get("name") or data.get("tag_name") or "",
|
|
134
|
+
"url": data.get("html_url") or RELEASES_PAGE,
|
|
135
|
+
"published_at": data.get("published_at"),
|
|
136
|
+
"notes": (data.get("body") or "")[:4000],
|
|
137
|
+
}
|
|
138
|
+
_cache.update(at=now, release=release)
|
|
139
|
+
return release
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _latest_commit(force: bool = False) -> dict | None:
|
|
143
|
+
"""The newest commit on GitHub's main branch."""
|
|
144
|
+
now = time.time()
|
|
145
|
+
if not force and _cache["commit_at"] and now - _cache["commit_at"] < CACHE_SECONDS:
|
|
146
|
+
return _cache["commit"]
|
|
147
|
+
data = _github(COMMIT_URL)
|
|
148
|
+
commit = None
|
|
149
|
+
if data:
|
|
150
|
+
info = data.get("commit") or {}
|
|
151
|
+
commit = {
|
|
152
|
+
"sha": data.get("sha") or "",
|
|
153
|
+
"date": (info.get("committer") or {}).get("date"),
|
|
154
|
+
"message": (info.get("message") or "").split("\n")[0][:200],
|
|
155
|
+
"url": data.get("html_url") or REPO_PAGE,
|
|
156
|
+
}
|
|
157
|
+
_cache.update(commit_at=now, commit=commit)
|
|
158
|
+
return commit
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _git(*args: str, timeout: int = 60) -> subprocess.CompletedProcess:
|
|
162
|
+
return subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True, timeout=timeout)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def local_commit() -> dict | None:
|
|
166
|
+
"""This checkout's commit (sha and unix time), or None without git."""
|
|
167
|
+
try:
|
|
168
|
+
out = _git("log", "-1", "--format=%H %ct", timeout=5).stdout.split()
|
|
169
|
+
return {"sha": out[0], "time": int(out[1])}
|
|
170
|
+
except (OSError, subprocess.SubprocessError, IndexError, ValueError):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def code_status(local: dict | None, remote: dict | None) -> dict | None:
|
|
175
|
+
"""Is GitHub's main branch newer than this checkout? Newer by commit time,
|
|
176
|
+
so local work that is not pushed yet never reads as an update."""
|
|
177
|
+
if not local or not remote or not remote.get("sha"):
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
remote_time = datetime.fromisoformat(str(remote["date"]).replace("Z", "+00:00")).timestamp()
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
remote_time = 0
|
|
183
|
+
same = remote["sha"] == local["sha"]
|
|
184
|
+
return {
|
|
185
|
+
"local_sha": local["sha"][:7],
|
|
186
|
+
"remote_sha": remote["sha"][:7],
|
|
187
|
+
"remote_date": remote.get("date"),
|
|
188
|
+
"message": remote.get("message"),
|
|
189
|
+
"url": remote.get("url"),
|
|
190
|
+
"update_available": not same and remote_time > local["time"],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# The last result, from a button press or the background thread.
|
|
195
|
+
_last: dict = {}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check(force: bool = False) -> dict:
|
|
199
|
+
kind = install_kind()
|
|
200
|
+
result = {
|
|
201
|
+
"current": __version__,
|
|
202
|
+
"install": kind,
|
|
203
|
+
"commands": UPDATE_STEPS[kind],
|
|
204
|
+
"can_apply": kind in ("source", "package"),
|
|
205
|
+
"repo_url": REPO_PAGE,
|
|
206
|
+
"releases_url": RELEASES_PAGE,
|
|
207
|
+
"checked_at": int(time.time()),
|
|
208
|
+
"settings": load_settings(),
|
|
209
|
+
}
|
|
210
|
+
try:
|
|
211
|
+
release = _latest_release(force)
|
|
212
|
+
code = code_status(local_commit(), _latest_commit(force)) if kind == "source" else None
|
|
213
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
214
|
+
result.update(latest=None, release=None, release_available=False, code=None, update_available=False,
|
|
215
|
+
error=f"Could not reach GitHub: {exc}")
|
|
216
|
+
else:
|
|
217
|
+
release_newer = bool(release and release["version"]) and is_newer(release["version"], __version__)
|
|
218
|
+
result.update(
|
|
219
|
+
latest=release["version"] if release else None,
|
|
220
|
+
release=release,
|
|
221
|
+
release_available=release_newer,
|
|
222
|
+
code=code,
|
|
223
|
+
# A git checkout follows main; a package / zip install follows releases.
|
|
224
|
+
update_available=release_newer or bool(code and code["update_available"]),
|
|
225
|
+
error=None,
|
|
226
|
+
)
|
|
227
|
+
_last.clear()
|
|
228
|
+
_last.update(result)
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_loop(first_delay: int = 30) -> None:
|
|
233
|
+
"""Background thread: check GitHub at the chosen interval (re-read each round)."""
|
|
234
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
235
|
+
return # tests never call GitHub
|
|
236
|
+
time.sleep(first_delay)
|
|
237
|
+
while True:
|
|
238
|
+
minutes = load_settings()["interval_minutes"]
|
|
239
|
+
if minutes:
|
|
240
|
+
try:
|
|
241
|
+
result = check()
|
|
242
|
+
if result.get("update_available"):
|
|
243
|
+
log.info("MarketMyna update available on GitHub (%s)", result.get("latest") or (result.get("code") or {}).get("remote_sha"))
|
|
244
|
+
except Exception: # never let the thread die
|
|
245
|
+
log.exception("Update check failed")
|
|
246
|
+
time.sleep(max(minutes, 5) * 60)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------ applying
|
|
250
|
+
|
|
251
|
+
_apply_lock = threading.Lock()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _run(cmd: list[str], logs: list[str], cwd: Path | None = None, timeout: int = 900) -> None:
|
|
255
|
+
logs.append("$ " + " ".join(cmd))
|
|
256
|
+
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
257
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
258
|
+
if output:
|
|
259
|
+
logs.append(output[-3000:])
|
|
260
|
+
if proc.returncode != 0:
|
|
261
|
+
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode})")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def apply_source(logs: list[str]) -> None:
|
|
265
|
+
"""Move this checkout to GitHub's main. Fast-forward when possible; when the
|
|
266
|
+
history was rewritten, keep the old commit on a backup branch first."""
|
|
267
|
+
if _git("status", "--porcelain", "--untracked-files=no", timeout=10).stdout.strip():
|
|
268
|
+
raise RuntimeError("This copy has local changes to MarketMyna's files. Commit or stash them, then update.")
|
|
269
|
+
_run(["git", "-C", str(ROOT), "fetch", REPO_GIT, BRANCH], logs)
|
|
270
|
+
head = _git("rev-parse", "HEAD", timeout=10).stdout.strip()
|
|
271
|
+
target = _git("rev-parse", "FETCH_HEAD", timeout=10).stdout.strip()
|
|
272
|
+
if head == target:
|
|
273
|
+
logs.append("Already at the latest code.")
|
|
274
|
+
elif _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", timeout=10).returncode == 0:
|
|
275
|
+
_run(["git", "-C", str(ROOT), "merge", "--ff-only", "FETCH_HEAD"], logs)
|
|
276
|
+
else:
|
|
277
|
+
backup = f"marketmyna-backup-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
278
|
+
_run(["git", "-C", str(ROOT), "branch", backup, "HEAD"], logs)
|
|
279
|
+
logs.append(f"Your previous commit is kept on branch {backup}.")
|
|
280
|
+
_run(["git", "-C", str(ROOT), "reset", "--hard", "FETCH_HEAD"], logs)
|
|
281
|
+
changed = _git("diff", "--name-only", head, "HEAD", timeout=10).stdout.split()
|
|
282
|
+
if "requirements-web.txt" in changed:
|
|
283
|
+
install_requirements(logs)
|
|
284
|
+
else:
|
|
285
|
+
logs.append("Requirements unchanged; nothing to install.")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def install_requirements(logs: list[str]) -> None:
|
|
289
|
+
"""pip install the requirements. A Python that refuses pip (a system Python,
|
|
290
|
+
PEP 668 "externally managed") is reported, not treated as a failed update."""
|
|
291
|
+
cmd = [sys.executable, "-m", "pip", "install", "-q", "-r", "requirements-web.txt"]
|
|
292
|
+
logs.append("$ " + " ".join(cmd))
|
|
293
|
+
proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=900)
|
|
294
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
295
|
+
if proc.returncode == 0:
|
|
296
|
+
if output:
|
|
297
|
+
logs.append(output[-3000:])
|
|
298
|
+
return
|
|
299
|
+
if "externally-managed-environment" in output:
|
|
300
|
+
logs.append("This Python does not allow pip installs (a system Python). The new requirements were "
|
|
301
|
+
"not installed: run `pip install -r requirements-web.txt` in your virtual environment.")
|
|
302
|
+
return
|
|
303
|
+
logs.append(output[-3000:])
|
|
304
|
+
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode})")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def apply_package(logs: list[str]) -> None:
|
|
308
|
+
_run([sys.executable, "-m", "pip", "install", "-q", "--upgrade", "marketmyna"], logs)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def restart_command(kind: str, argv: list[str]) -> list[str]:
|
|
312
|
+
"""How to start this app again with the same options (and no new browser tab)."""
|
|
313
|
+
args = [a for a in argv if a != "--rebuild"]
|
|
314
|
+
if "--no-browser" not in args:
|
|
315
|
+
args.append("--no-browser")
|
|
316
|
+
if kind == "source":
|
|
317
|
+
return [sys.executable, str(ROOT / "start.py"), *args]
|
|
318
|
+
return [sys.executable, "-m", "marketmyna.launcher", *args]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def restart(kind: str, delay: float = 1.5) -> None:
|
|
322
|
+
"""Start the app again with the same options. Linux / macOS replace this
|
|
323
|
+
process in place (same terminal, Ctrl+C still works; the listening socket is
|
|
324
|
+
closed on exec, so the port is free). Windows starts a fresh copy once this
|
|
325
|
+
one has exited. A source install rebuilds the frontend on that start."""
|
|
326
|
+
cmd = restart_command(kind, sys.argv[1:])
|
|
327
|
+
cwd = str(ROOT) if kind == "source" else os.getcwd()
|
|
328
|
+
|
|
329
|
+
def go() -> None:
|
|
330
|
+
time.sleep(delay) # let the HTTP answer reach the browser
|
|
331
|
+
sys.stdout.flush()
|
|
332
|
+
sys.stderr.flush()
|
|
333
|
+
if os.name != "nt":
|
|
334
|
+
os.chdir(cwd)
|
|
335
|
+
os.execv(cmd[0], cmd)
|
|
336
|
+
helper = f"import subprocess, time; time.sleep(3); subprocess.Popen({cmd!r}, cwd={cwd!r})"
|
|
337
|
+
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
338
|
+
subprocess.Popen([sys.executable, "-c", helper], creationflags=flags)
|
|
339
|
+
os._exit(0)
|
|
340
|
+
|
|
341
|
+
threading.Thread(target=go, name="update-restart", daemon=True).start()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------ routes
|
|
345
|
+
|
|
346
|
+
@router.get("")
|
|
347
|
+
def get_updates(force: bool = False) -> dict:
|
|
348
|
+
"""force=true (the button) asks GitHub now; otherwise a recent answer is reused."""
|
|
349
|
+
return check(force=force)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@router.get("/status")
|
|
353
|
+
def last_status() -> dict:
|
|
354
|
+
"""What the background thread found last (for the sidebar); never calls GitHub."""
|
|
355
|
+
return dict(_last) if _last else {"current": __version__, "update_available": False, "checked_at": None}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class UpdateSettingsIn(BaseModel):
|
|
359
|
+
interval_minutes: int
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@router.put("/settings")
|
|
363
|
+
def put_settings(body: UpdateSettingsIn) -> dict:
|
|
364
|
+
if body.interval_minutes not in INTERVALS:
|
|
365
|
+
raise HTTPException(status_code=400, detail=f"interval_minutes must be one of {list(INTERVALS)}")
|
|
366
|
+
settings = {"interval_minutes": body.interval_minutes}
|
|
367
|
+
save_settings(settings)
|
|
368
|
+
return settings
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@router.post("/apply")
|
|
372
|
+
def apply_update() -> dict:
|
|
373
|
+
kind = install_kind()
|
|
374
|
+
if kind == "zip":
|
|
375
|
+
raise HTTPException(status_code=400, detail="A release-zip copy is updated by downloading the new zip from the releases page.")
|
|
376
|
+
if not _apply_lock.acquire(blocking=False):
|
|
377
|
+
raise HTTPException(status_code=409, detail="An update is already running.")
|
|
378
|
+
logs: list[str] = []
|
|
379
|
+
try:
|
|
380
|
+
status = check(force=True)
|
|
381
|
+
if status.get("error"):
|
|
382
|
+
raise HTTPException(status_code=502, detail=status["error"])
|
|
383
|
+
if not status.get("update_available"):
|
|
384
|
+
return {"updated": False, "restarting": False, "log": ["Already up to date."]}
|
|
385
|
+
try:
|
|
386
|
+
(apply_source if kind == "source" else apply_package)(logs)
|
|
387
|
+
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
388
|
+
tail = "\n".join(logs[-6:])
|
|
389
|
+
raise HTTPException(status_code=500, detail=f"{exc}\n\n{tail}".strip()) from exc
|
|
390
|
+
logs.append("Update installed. Restarting MarketMyna...")
|
|
391
|
+
restart(kind)
|
|
392
|
+
return {"updated": True, "restarting": True, "log": logs}
|
|
393
|
+
finally:
|
|
394
|
+
_apply_lock.release()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Watchlists: named lists of instruments to track (no quantities or cost -
|
|
2
|
+
that's what portfolios are for). Items are enriched with live price data
|
|
3
|
+
from the market listing cache (marketmyna/data/listings.py)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from sqlalchemy.exc import IntegrityError
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
|
|
14
|
+
from marketmyna.api.deps import get_config_dep, get_db_session
|
|
15
|
+
from marketmyna.data.listings import MARKET_LABELS, get_store
|
|
16
|
+
from marketmyna.storage import WatchlistItemRecord, WatchlistRecord
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
DEFAULT_WATCHLIST = "My watchlist"
|
|
21
|
+
|
|
22
|
+
# Listing fields copied onto each item so the page can render a full row.
|
|
23
|
+
_LIVE_FIELDS = (
|
|
24
|
+
"name", "exchange", "sector", "category", "category_inferred", "price", "change",
|
|
25
|
+
"change_percent", "volume", "market_cap", "high_52w", "low_52w", "currency",
|
|
26
|
+
"name_local", "amc", "fund_group", "plan", "option", "isin", "nav_date",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WatchlistBody(BaseModel):
|
|
31
|
+
name: str = Field(min_length=1, max_length=60)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ItemBody(BaseModel):
|
|
35
|
+
symbol: str = Field(min_length=1, max_length=40)
|
|
36
|
+
market: str = "us"
|
|
37
|
+
type: str = "stock"
|
|
38
|
+
name: str = ""
|
|
39
|
+
note: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _summary(record: WatchlistRecord) -> dict:
|
|
43
|
+
return {"id": record.id, "name": record.name, "count": len(record.items)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _all(db: Session) -> list[WatchlistRecord]:
|
|
47
|
+
records = db.query(WatchlistRecord).order_by(WatchlistRecord.id).all()
|
|
48
|
+
if not records:
|
|
49
|
+
# Everyone starts with one list, so "add to watchlist" always has a target.
|
|
50
|
+
# A savepoint, so a parallel first request creating the same list
|
|
51
|
+
# (unique per user) only loses its insert, not the whole request.
|
|
52
|
+
try:
|
|
53
|
+
with db.begin_nested():
|
|
54
|
+
db.add(WatchlistRecord(name=DEFAULT_WATCHLIST))
|
|
55
|
+
except IntegrityError:
|
|
56
|
+
pass
|
|
57
|
+
records = db.query(WatchlistRecord).order_by(WatchlistRecord.id).all()
|
|
58
|
+
return records
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get(db: Session, watchlist_id: int) -> WatchlistRecord:
|
|
62
|
+
record = db.get(WatchlistRecord, watchlist_id)
|
|
63
|
+
if record is None:
|
|
64
|
+
raise HTTPException(status_code=404, detail=f"Watchlist {watchlist_id} not found")
|
|
65
|
+
return record
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _item_dict(item: WatchlistItemRecord, live: Optional[dict]) -> dict:
|
|
69
|
+
out = {
|
|
70
|
+
"id": item.id,
|
|
71
|
+
"watchlist_id": item.watchlist_id,
|
|
72
|
+
"symbol": item.symbol,
|
|
73
|
+
"market": item.market,
|
|
74
|
+
"type": item.type,
|
|
75
|
+
"name": item.name or item.symbol,
|
|
76
|
+
"note": item.note or "",
|
|
77
|
+
"added_at": item.added_at.isoformat() if item.added_at else None,
|
|
78
|
+
"listed": live is not None,
|
|
79
|
+
}
|
|
80
|
+
if live:
|
|
81
|
+
out.update({k: live.get(k) for k in _LIVE_FIELDS if k in live})
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _unique_name(db: Session, name: str, exclude_id: Optional[int] = None) -> str:
|
|
86
|
+
name = name.strip()
|
|
87
|
+
if not name:
|
|
88
|
+
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
|
89
|
+
clash = db.query(WatchlistRecord).filter(WatchlistRecord.name == name).first()
|
|
90
|
+
if clash and clash.id != exclude_id:
|
|
91
|
+
raise HTTPException(status_code=409, detail=f"A watchlist named '{name}' already exists")
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/watchlists")
|
|
96
|
+
def list_watchlists(db: Session = Depends(get_db_session), config=Depends(get_config_dep)) -> list[dict]:
|
|
97
|
+
return [_summary(r) for r in _all(db)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.post("/watchlists", status_code=201)
|
|
101
|
+
def create_watchlist(body: WatchlistBody, db: Session = Depends(get_db_session),
|
|
102
|
+
config=Depends(get_config_dep)) -> dict:
|
|
103
|
+
record = WatchlistRecord(name=_unique_name(db, body.name))
|
|
104
|
+
db.add(record)
|
|
105
|
+
db.flush()
|
|
106
|
+
return _summary(record)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.patch("/watchlists/{watchlist_id}")
|
|
110
|
+
def rename_watchlist(watchlist_id: int, body: WatchlistBody, db: Session = Depends(get_db_session)) -> dict:
|
|
111
|
+
record = _get(db, watchlist_id)
|
|
112
|
+
record.name = _unique_name(db, body.name, exclude_id=record.id)
|
|
113
|
+
return _summary(record)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.delete("/watchlists/{watchlist_id}", status_code=204)
|
|
117
|
+
def delete_watchlist(watchlist_id: int, db: Session = Depends(get_db_session)) -> Response:
|
|
118
|
+
db.delete(_get(db, watchlist_id))
|
|
119
|
+
return Response(status_code=204)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.get("/watchlists/items")
|
|
123
|
+
def all_items(db: Session = Depends(get_db_session)) -> list[dict]:
|
|
124
|
+
"""Every item across lists (no live data) - lets the UI mark stars."""
|
|
125
|
+
_all(db)
|
|
126
|
+
# The join keeps items to the caller's own lists (watchlists are user-scoped).
|
|
127
|
+
items = (
|
|
128
|
+
db.query(WatchlistItemRecord)
|
|
129
|
+
.join(WatchlistRecord, WatchlistItemRecord.watchlist_id == WatchlistRecord.id)
|
|
130
|
+
.order_by(WatchlistItemRecord.id)
|
|
131
|
+
.all()
|
|
132
|
+
)
|
|
133
|
+
return [
|
|
134
|
+
{"id": i.id, "watchlist_id": i.watchlist_id, "symbol": i.symbol, "market": i.market, "type": i.type}
|
|
135
|
+
for i in items
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.get("/watchlists/{watchlist_id}")
|
|
140
|
+
def get_watchlist(watchlist_id: int, db: Session = Depends(get_db_session), config=Depends(get_config_dep)) -> dict:
|
|
141
|
+
record = _get(db, watchlist_id)
|
|
142
|
+
keys = [(i.market, i.type, i.symbol) for i in record.items if i.market in MARKET_LABELS]
|
|
143
|
+
live = get_store().lookup(keys) if keys else {}
|
|
144
|
+
return {
|
|
145
|
+
**_summary(record),
|
|
146
|
+
"items": [_item_dict(i, live.get((i.market, i.type, i.symbol))) for i in record.items],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.post("/watchlists/{watchlist_id}/items", status_code=201)
|
|
151
|
+
def add_item(watchlist_id: int, body: ItemBody, db: Session = Depends(get_db_session),
|
|
152
|
+
config=Depends(get_config_dep)) -> dict:
|
|
153
|
+
record = _get(db, watchlist_id)
|
|
154
|
+
symbol = body.symbol.strip().upper() if body.type != "mf" else body.symbol.strip()
|
|
155
|
+
market = body.market.strip().lower()
|
|
156
|
+
existing = next(
|
|
157
|
+
(i for i in record.items if i.symbol == symbol and i.market == market and i.type == body.type), None
|
|
158
|
+
)
|
|
159
|
+
if existing:
|
|
160
|
+
return _item_dict(existing, None)
|
|
161
|
+
item = WatchlistItemRecord(
|
|
162
|
+
watchlist_id=record.id, symbol=symbol, market=market, type=body.type, name=body.name, note=body.note
|
|
163
|
+
)
|
|
164
|
+
db.add(item)
|
|
165
|
+
try:
|
|
166
|
+
db.flush()
|
|
167
|
+
except IntegrityError:
|
|
168
|
+
raise HTTPException(status_code=409, detail=f"{symbol} is already in this watchlist")
|
|
169
|
+
return _item_dict(item, None)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.delete("/watchlists/{watchlist_id}/items/{item_id}", status_code=204)
|
|
173
|
+
def remove_item(watchlist_id: int, item_id: int, db: Session = Depends(get_db_session)) -> Response:
|
|
174
|
+
record = _get(db, watchlist_id) # 404s for someone else's list
|
|
175
|
+
item = db.get(WatchlistItemRecord, item_id)
|
|
176
|
+
if item is None or item.watchlist_id != record.id:
|
|
177
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
178
|
+
db.delete(item)
|
|
179
|
+
return Response(status_code=204)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Aggregates all v1 endpoint routers."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from marketmyna.api.v1.endpoints import (
|
|
6
|
+
agent,
|
|
7
|
+
bot,
|
|
8
|
+
calendar,
|
|
9
|
+
data_sources,
|
|
10
|
+
health,
|
|
11
|
+
listings,
|
|
12
|
+
macro,
|
|
13
|
+
portfolio,
|
|
14
|
+
push,
|
|
15
|
+
reports,
|
|
16
|
+
scheduler,
|
|
17
|
+
stocks,
|
|
18
|
+
system_config,
|
|
19
|
+
thesis,
|
|
20
|
+
trade,
|
|
21
|
+
updates,
|
|
22
|
+
watchlists,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
router.include_router(health.router, tags=["health"])
|
|
28
|
+
router.include_router(data_sources.router, tags=["data-sources"])
|
|
29
|
+
router.include_router(stocks.router, tags=["stocks"])
|
|
30
|
+
router.include_router(listings.router, tags=["listings"])
|
|
31
|
+
router.include_router(portfolio.router, tags=["portfolio"])
|
|
32
|
+
router.include_router(watchlists.router, tags=["watchlists"])
|
|
33
|
+
router.include_router(system_config.router, tags=["system-config"])
|
|
34
|
+
router.include_router(agent.router, tags=["agent"])
|
|
35
|
+
router.include_router(scheduler.router, tags=["scheduler"])
|
|
36
|
+
router.include_router(bot.router, tags=["bot"])
|
|
37
|
+
router.include_router(push.router, tags=["push"])
|
|
38
|
+
router.include_router(reports.router, tags=["reports"])
|
|
39
|
+
router.include_router(trade.router, tags=["trade"])
|
|
40
|
+
router.include_router(calendar.router, tags=["calendar"])
|
|
41
|
+
router.include_router(macro.router, tags=["macro"])
|
|
42
|
+
router.include_router(thesis.router, tags=["thesis"])
|
|
43
|
+
router.include_router(updates.router, tags=["updates"])
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Platform-agnostic chat-bot command layer.
|
|
2
|
+
|
|
3
|
+
An IncomingMessage (from any platform: Telegram today, a plain HTTP
|
|
4
|
+
test endpoint, or a future Discord/Slack listener) goes through
|
|
5
|
+
CommandDispatcher, which routes it to a registered Command and returns
|
|
6
|
+
a CommandResult - the same text response regardless of where the
|
|
7
|
+
message came from.
|
|
8
|
+
|
|
9
|
+
Only one real inbound listener exists so far: marketmyna/bot/telegram_listener.py
|
|
10
|
+
(long-polling). The 13 other outbound-only notifier channels in
|
|
11
|
+
marketmyna/notifiers/ do NOT have an inbound counterpart yet - that's real,
|
|
12
|
+
separate follow-up work per platform, not silently implied by this
|
|
13
|
+
package's existence.
|
|
14
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""/analyze SYMBOL [market] [mode] - runs the full agent pipeline and
|
|
2
|
+
replies with a short readable summary."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ...agent.dashboard import build_dashboard
|
|
7
|
+
from ...agent.orchestrator import AgentOrchestrator
|
|
8
|
+
from ..formatting import format_dashboard
|
|
9
|
+
from ..models import CommandResult, IncomingMessage
|
|
10
|
+
from .base import Command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnalyzeCommand(Command):
|
|
14
|
+
name = "analyze"
|
|
15
|
+
aliases = ["a"]
|
|
16
|
+
usage = "/analyze SYMBOL [market] [mode]"
|
|
17
|
+
description = "Full multi-agent AI analysis of one stock (technical, risk, news, decision)."
|
|
18
|
+
group = "AI analysis"
|
|
19
|
+
example = "/analyze AAPL us standard"
|
|
20
|
+
help_text = "/analyze SYMBOL [market] [mode] - run the agent pipeline on one symbol (default market=us, mode=standard)"
|
|
21
|
+
|
|
22
|
+
def __init__(self, config):
|
|
23
|
+
self._config = config
|
|
24
|
+
|
|
25
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
26
|
+
parts = args.split()
|
|
27
|
+
if not parts:
|
|
28
|
+
return CommandResult(text=f"Usage: {self.help_text}", success=False)
|
|
29
|
+
|
|
30
|
+
symbol = parts[0]
|
|
31
|
+
market = parts[1] if len(parts) > 1 else "us"
|
|
32
|
+
mode = parts[2] if len(parts) > 2 else "standard"
|
|
33
|
+
|
|
34
|
+
orchestrator = AgentOrchestrator(config=self._config)
|
|
35
|
+
try:
|
|
36
|
+
ctx = orchestrator.run(symbol, market, mode=mode)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
return CommandResult(text=f"Analysis failed for {symbol}: {exc}", success=False)
|
|
39
|
+
|
|
40
|
+
return CommandResult(text=format_dashboard(build_dashboard(ctx)))
|