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,119 @@
|
|
|
1
|
+
"""Browser push notification endpoints: the server's VAPID public key,
|
|
2
|
+
device (subscription) management and a test push."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from sqlalchemy import or_
|
|
12
|
+
|
|
13
|
+
from marketmyna.api.deps import get_config_dep
|
|
14
|
+
from marketmyna.core import tenant
|
|
15
|
+
from marketmyna.notifiers.webpush import WebPushSender, ensure_vapid_key, vapid_public_key
|
|
16
|
+
from marketmyna.storage import DatabaseManager, WebPushSubscriptionRecord, get_db_url
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SubscriptionKeys(BaseModel):
|
|
22
|
+
p256dh: str = Field(min_length=10)
|
|
23
|
+
auth: str = Field(min_length=4)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SubscriptionBody(BaseModel):
|
|
27
|
+
endpoint: str = Field(min_length=10)
|
|
28
|
+
keys: SubscriptionKeys
|
|
29
|
+
label: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EndpointBody(BaseModel):
|
|
33
|
+
endpoint: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _db(config):
|
|
37
|
+
return DatabaseManager.instance(get_db_url(config=config))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _device(record: WebPushSubscriptionRecord) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"id": record.id,
|
|
43
|
+
"label": record.label or "Browser",
|
|
44
|
+
"service": urlsplit(record.endpoint).netloc,
|
|
45
|
+
"created_at": record.created_at.isoformat() if record.created_at else None,
|
|
46
|
+
"last_success_at": record.last_success_at.isoformat() if record.last_success_at else None,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/push/vapid-public-key")
|
|
51
|
+
def get_vapid_public_key(config=Depends(get_config_dep)) -> dict:
|
|
52
|
+
return {"public_key": vapid_public_key(ensure_vapid_key(config))}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/push/subscriptions")
|
|
56
|
+
def list_subscriptions(config=Depends(get_config_dep)) -> list[dict]:
|
|
57
|
+
with _db(config).get_session() as session:
|
|
58
|
+
records = session.query(WebPushSubscriptionRecord).order_by(WebPushSubscriptionRecord.id).all()
|
|
59
|
+
return [_device(r) for r in records]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/push/subscriptions", status_code=201)
|
|
63
|
+
def subscribe(body: SubscriptionBody, config=Depends(get_config_dep)) -> dict:
|
|
64
|
+
if not body.endpoint.startswith("https://"):
|
|
65
|
+
raise HTTPException(status_code=400, detail="Push endpoint must be https")
|
|
66
|
+
ensure_vapid_key(config)
|
|
67
|
+
with _db(config).get_session() as session:
|
|
68
|
+
if tenant.current_user_id() is not None:
|
|
69
|
+
# Accounts mode: a browser belongs to whoever subscribed on it last
|
|
70
|
+
# (endpoints are unique across users).
|
|
71
|
+
with tenant.system_scope():
|
|
72
|
+
session.query(WebPushSubscriptionRecord).filter(
|
|
73
|
+
WebPushSubscriptionRecord.endpoint == body.endpoint,
|
|
74
|
+
or_(WebPushSubscriptionRecord.user_id.is_(None),
|
|
75
|
+
WebPushSubscriptionRecord.user_id != tenant.current_user_id()),
|
|
76
|
+
).delete(synchronize_session=False)
|
|
77
|
+
record = (
|
|
78
|
+
session.query(WebPushSubscriptionRecord)
|
|
79
|
+
.filter(WebPushSubscriptionRecord.endpoint == body.endpoint)
|
|
80
|
+
.first()
|
|
81
|
+
)
|
|
82
|
+
if record is None:
|
|
83
|
+
record = WebPushSubscriptionRecord(endpoint=body.endpoint)
|
|
84
|
+
session.add(record)
|
|
85
|
+
record.p256dh = body.keys.p256dh
|
|
86
|
+
record.auth = body.keys.auth
|
|
87
|
+
record.label = body.label[:80]
|
|
88
|
+
session.flush()
|
|
89
|
+
return _device(record)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.post("/push/unsubscribe", status_code=204)
|
|
93
|
+
def unsubscribe(body: EndpointBody, config=Depends(get_config_dep)) -> Response:
|
|
94
|
+
with _db(config).get_session() as session:
|
|
95
|
+
session.query(WebPushSubscriptionRecord).filter(
|
|
96
|
+
WebPushSubscriptionRecord.endpoint == body.endpoint
|
|
97
|
+
).delete(synchronize_session=False)
|
|
98
|
+
return Response(status_code=204)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.delete("/push/subscriptions/{sub_id}", status_code=204)
|
|
102
|
+
def delete_subscription(sub_id: int, config=Depends(get_config_dep)) -> Response:
|
|
103
|
+
with _db(config).get_session() as session:
|
|
104
|
+
deleted = session.query(WebPushSubscriptionRecord).filter(WebPushSubscriptionRecord.id == sub_id).delete(
|
|
105
|
+
synchronize_session=False
|
|
106
|
+
)
|
|
107
|
+
if not deleted:
|
|
108
|
+
raise HTTPException(status_code=404, detail="Device not found")
|
|
109
|
+
return Response(status_code=204)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.post("/push/test")
|
|
113
|
+
def test_push(config=Depends(get_config_dep), url: Optional[str] = None) -> dict:
|
|
114
|
+
sender = WebPushSender(config)
|
|
115
|
+
if not sender.is_configured():
|
|
116
|
+
return {"sent": False, "message": "No device has enabled push notifications yet."}
|
|
117
|
+
sent = sender.send("Push notifications are working. Reports and alerts will arrive here.",
|
|
118
|
+
title="MarketMyna test", url=url or "/")
|
|
119
|
+
return {"sent": sent, "message": "Sent" if sent else "Every device rejected the push - see server logs."}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Saved smart reports: the full table behind a smart message's link.
|
|
2
|
+
|
|
3
|
+
A report is addressed only by its random token (unguessable, like a share
|
|
4
|
+
link), so the /r/<token> page works for anyone the link was sent to."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
|
|
10
|
+
from marketmyna.api.deps import get_config_dep
|
|
11
|
+
from marketmyna.storage import DatabaseManager, ReportRunRecord, get_db_url
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _summary(rec: ReportRunRecord) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"token": rec.token,
|
|
19
|
+
"title": rec.title,
|
|
20
|
+
"created_at": rec.created_at.isoformat() if rec.created_at else None,
|
|
21
|
+
"stock_count": rec.stock_count,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/reports")
|
|
26
|
+
def list_reports(limit: int = 20, config=Depends(get_config_dep)) -> list[dict]:
|
|
27
|
+
db = DatabaseManager.instance(get_db_url(config=config))
|
|
28
|
+
with db.get_session() as session:
|
|
29
|
+
recs = (
|
|
30
|
+
session.query(ReportRunRecord)
|
|
31
|
+
.order_by(ReportRunRecord.created_at.desc(), ReportRunRecord.id.desc())
|
|
32
|
+
.limit(max(1, min(100, limit)))
|
|
33
|
+
.all()
|
|
34
|
+
)
|
|
35
|
+
return [_summary(r) for r in recs]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/reports/{token}")
|
|
39
|
+
def get_report(token: str, config=Depends(get_config_dep)) -> dict:
|
|
40
|
+
db = DatabaseManager.instance(get_db_url(config=config))
|
|
41
|
+
with db.get_session() as session:
|
|
42
|
+
rec = session.query(ReportRunRecord).filter_by(token=token).first()
|
|
43
|
+
if rec is None:
|
|
44
|
+
raise HTTPException(status_code=404, detail="Report not found")
|
|
45
|
+
from marketmyna.services.smart_report import without_calls
|
|
46
|
+
|
|
47
|
+
# Reports saved before calls were removed still hold them: strip on the way out.
|
|
48
|
+
rows = [r if "reading" in r or "error" in r else without_calls(r) for r in rec.rows or []]
|
|
49
|
+
return {**_summary(rec), "rows": rows, "meta": {**(rec.meta or {}), "compliance": "analytics"}}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Runtime scheduler endpoints: persisted schedule config, status, and a
|
|
2
|
+
manual "run now" that shares the exact same run path
|
|
3
|
+
(RuntimeSchedulerService.run_now()) the background timer loop uses -
|
|
4
|
+
there is only one "do a run" code path in the whole service."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from marketmyna.api.deps import get_config_dep, get_scheduler_service
|
|
14
|
+
from marketmyna.core.compliance import mode as compliance_mode
|
|
15
|
+
from marketmyna.llm.channels import llm_configured
|
|
16
|
+
from marketmyna.notifiers.dispatcher import NotificationDispatcher
|
|
17
|
+
from marketmyna.services import schedule_time
|
|
18
|
+
from marketmyna.services.report import DEFAULT_SECTIONS, SECTION_AREAS, SECTIONS, clean_sections
|
|
19
|
+
from marketmyna.services.report_i18n import BUILTIN_LANGUAGES, LANGUAGES
|
|
20
|
+
from marketmyna.services.schedule_config import (
|
|
21
|
+
SMART_FILE_CHANNELS,
|
|
22
|
+
SMART_FILES,
|
|
23
|
+
SOURCES,
|
|
24
|
+
ScheduleConfig,
|
|
25
|
+
ScheduleConfigStore,
|
|
26
|
+
resolve_targets,
|
|
27
|
+
source_catalog,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
router = APIRouter()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScheduleConfigUpdate(BaseModel):
|
|
34
|
+
enabled: Optional[bool] = None
|
|
35
|
+
interval_minutes: Optional[int] = None
|
|
36
|
+
time_of_day: Optional[str] = None
|
|
37
|
+
symbols: Optional[List[str]] = None
|
|
38
|
+
market: Optional[str] = None
|
|
39
|
+
mode: Optional[str] = None
|
|
40
|
+
report_language: Optional[str] = None
|
|
41
|
+
report_sections: Optional[List[str]] = None
|
|
42
|
+
stock_sections: Optional[Dict[str, List[str]]] = None
|
|
43
|
+
channel_languages: Optional[Dict[str, str]] = None
|
|
44
|
+
mf_codes: Optional[List[str]] = None
|
|
45
|
+
smart_enabled: Optional[bool] = None
|
|
46
|
+
smart_top_n: Optional[int] = None
|
|
47
|
+
smart_ai_top: Optional[int] = None
|
|
48
|
+
smart_email_full: Optional[bool] = None
|
|
49
|
+
smart_file: Optional[str] = None
|
|
50
|
+
smart_file_channels: Optional[List[str]] = None
|
|
51
|
+
report_base_url: Optional[str] = None
|
|
52
|
+
sources: Optional[List[str]] = None
|
|
53
|
+
watchlist_ids: Optional[List[int]] = None
|
|
54
|
+
portfolio_names: Optional[List[str]] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_dict(cfg: ScheduleConfig, config=None) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"enabled": cfg.enabled,
|
|
60
|
+
"interval_minutes": cfg.interval_minutes,
|
|
61
|
+
"time_of_day": cfg.time_of_day,
|
|
62
|
+
"symbols": cfg.symbols,
|
|
63
|
+
"market": cfg.market,
|
|
64
|
+
"mode": cfg.mode,
|
|
65
|
+
"last_run_at": cfg.last_run_at.isoformat() if cfg.last_run_at else None,
|
|
66
|
+
"last_run_status": cfg.last_run_status,
|
|
67
|
+
"last_run_summary": cfg.last_run_summary,
|
|
68
|
+
"report_language": cfg.report_language or "en",
|
|
69
|
+
"report_sections": cfg.report_sections if cfg.report_sections is not None else list(DEFAULT_SECTIONS),
|
|
70
|
+
"stock_sections": cfg.stock_sections or {},
|
|
71
|
+
"channel_languages": cfg.channel_languages or {},
|
|
72
|
+
"mf_codes": cfg.mf_codes or [],
|
|
73
|
+
"smart_enabled": bool(cfg.smart_enabled),
|
|
74
|
+
"smart_top_n": cfg.smart_top_n or 5,
|
|
75
|
+
"smart_ai_top": cfg.smart_ai_top if cfg.smart_ai_top is not None else 10,
|
|
76
|
+
"smart_email_full": cfg.smart_email_full if cfg.smart_email_full is not None else True,
|
|
77
|
+
"smart_file": cfg.smart_file if cfg.smart_file in SMART_FILES else "pdf",
|
|
78
|
+
"smart_file_channels": list(cfg.smart_file_channels if cfg.smart_file_channels is not None
|
|
79
|
+
else SMART_FILE_CHANNELS),
|
|
80
|
+
"report_base_url": cfg.report_base_url or "",
|
|
81
|
+
"sources": cfg.run_sources(),
|
|
82
|
+
"watchlist_ids": cfg.watchlist_ids or [],
|
|
83
|
+
"portfolio_names": cfg.portfolio_names or [],
|
|
84
|
+
"stock_count": cfg.stock_count,
|
|
85
|
+
"area": cfg.area,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stock_count(cfg: ScheduleConfig, config) -> int:
|
|
90
|
+
"""Stocks the run will cover."""
|
|
91
|
+
try:
|
|
92
|
+
return len(resolve_targets(cfg, config))
|
|
93
|
+
except Exception:
|
|
94
|
+
return cfg.stock_count or 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/scheduler/config")
|
|
98
|
+
def get_schedule_config(config=Depends(get_config_dep)) -> dict:
|
|
99
|
+
return _to_dict(ScheduleConfigStore(config=config).load(), config)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _session_area(request: Request, config) -> Optional[str]:
|
|
103
|
+
"""The area ("trader" / "investor") the owner is working in, or None."""
|
|
104
|
+
if not getattr(config, "accounts_enabled", False):
|
|
105
|
+
return None
|
|
106
|
+
from marketmyna.api.middlewares.auth import accounts_db, current_user_from_request
|
|
107
|
+
|
|
108
|
+
with accounts_db(config).get_session() as session:
|
|
109
|
+
user = current_user_from_request(request, session)
|
|
110
|
+
return user.last_area if user is not None else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.put("/scheduler/config")
|
|
114
|
+
def update_schedule_config(body: ScheduleConfigUpdate, request: Request, config=Depends(get_config_dep)) -> dict:
|
|
115
|
+
overrides = body.model_dump(exclude_unset=True)
|
|
116
|
+
# The report follows the area it was set up in (its sections only).
|
|
117
|
+
area = _session_area(request, config)
|
|
118
|
+
if area:
|
|
119
|
+
overrides["area"] = area
|
|
120
|
+
if overrides.get("time_of_day"):
|
|
121
|
+
try:
|
|
122
|
+
hour, minute = schedule_time.parse_time(overrides["time_of_day"])
|
|
123
|
+
except ValueError:
|
|
124
|
+
raise HTTPException(status_code=400, detail="Use a 24-hour time such as 08:45 or 18:30.")
|
|
125
|
+
overrides["time_of_day"] = f"{hour:02d}:{minute:02d}"
|
|
126
|
+
if overrides.get("interval_minutes") is not None and overrides["interval_minutes"] < 1:
|
|
127
|
+
raise HTTPException(status_code=400, detail="Repeat every 1 minute or more.")
|
|
128
|
+
if "report_language" in overrides and overrides["report_language"] not in LANGUAGES:
|
|
129
|
+
raise HTTPException(status_code=400, detail=f"Unknown language '{overrides['report_language']}'")
|
|
130
|
+
if overrides.get("report_sections") is not None:
|
|
131
|
+
overrides["report_sections"] = clean_sections(overrides["report_sections"])
|
|
132
|
+
if overrides.get("stock_sections") is not None:
|
|
133
|
+
overrides["stock_sections"] = {
|
|
134
|
+
sym.strip().upper(): clean_sections(secs) for sym, secs in overrides["stock_sections"].items() if sym.strip()
|
|
135
|
+
}
|
|
136
|
+
if overrides.get("channel_languages") is not None:
|
|
137
|
+
bad = [lang for lang in overrides["channel_languages"].values() if lang and lang not in LANGUAGES]
|
|
138
|
+
if bad:
|
|
139
|
+
raise HTTPException(status_code=400, detail=f"Unknown language '{bad[0]}'")
|
|
140
|
+
overrides["channel_languages"] = {ch: lang for ch, lang in overrides["channel_languages"].items() if lang}
|
|
141
|
+
if overrides.get("mf_codes") is not None:
|
|
142
|
+
overrides["mf_codes"] = [c.strip() for c in overrides["mf_codes"] if c.strip().isdigit()]
|
|
143
|
+
if overrides.get("smart_top_n") is not None:
|
|
144
|
+
overrides["smart_top_n"] = max(1, min(20, overrides["smart_top_n"]))
|
|
145
|
+
if overrides.get("smart_ai_top") is not None:
|
|
146
|
+
overrides["smart_ai_top"] = max(0, min(50, overrides["smart_ai_top"]))
|
|
147
|
+
if overrides.get("smart_file") is not None and overrides["smart_file"] not in SMART_FILES:
|
|
148
|
+
raise HTTPException(status_code=400, detail="Report file must be pdf, xlsx or none.")
|
|
149
|
+
if overrides.get("smart_file_channels") is not None:
|
|
150
|
+
overrides["smart_file_channels"] = [c for c in SMART_FILE_CHANNELS if c in overrides["smart_file_channels"]]
|
|
151
|
+
if "report_base_url" in overrides:
|
|
152
|
+
url = (overrides["report_base_url"] or "").strip().rstrip("/")
|
|
153
|
+
if url and not url.startswith(("http://", "https://")):
|
|
154
|
+
raise HTTPException(status_code=400, detail="Public address must start with http:// or https://")
|
|
155
|
+
overrides["report_base_url"] = url or None
|
|
156
|
+
if overrides.get("sources") is not None:
|
|
157
|
+
overrides["sources"] = [s for s in SOURCES if s in overrides["sources"]]
|
|
158
|
+
store = ScheduleConfigStore(config=config)
|
|
159
|
+
after = store.load()
|
|
160
|
+
for key, value in overrides.items():
|
|
161
|
+
setattr(after, key, value)
|
|
162
|
+
after.stock_count = _stock_count(after, config)
|
|
163
|
+
store.save(after)
|
|
164
|
+
return _to_dict(after, config)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.get("/scheduler/status")
|
|
168
|
+
def get_scheduler_status(scheduler=Depends(get_scheduler_service), config=Depends(get_config_dep)) -> dict:
|
|
169
|
+
cfg = ScheduleConfigStore(config=config).load()
|
|
170
|
+
return {"running": scheduler.is_running(), "schedule": _to_dict(cfg, config)}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@router.post("/scheduler/run-now")
|
|
174
|
+
def run_now(request: Request, scheduler=Depends(get_scheduler_service), config=Depends(get_config_dep)) -> dict:
|
|
175
|
+
"""Starts an immediate out-of-band run in the background, independent of
|
|
176
|
+
the schedule/timer - same watchdog-guarded child-process path and
|
|
177
|
+
notification push as a normal scheduled run. GET /scheduler/run-now
|
|
178
|
+
tells when it's done."""
|
|
179
|
+
area = _session_area(request, config)
|
|
180
|
+
store = ScheduleConfigStore(config=config)
|
|
181
|
+
if area and store.load().area != area:
|
|
182
|
+
store.update(area=area) # "Run now" from the investor area gives the investor report
|
|
183
|
+
if not scheduler.start_run_now():
|
|
184
|
+
raise HTTPException(status_code=409, detail="Your report is already running.")
|
|
185
|
+
return {"started": True}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.get("/scheduler/run-now")
|
|
189
|
+
def run_now_state(scheduler=Depends(get_scheduler_service)) -> dict:
|
|
190
|
+
"""{"running": bool, "result": the last Run now's outcome or None}."""
|
|
191
|
+
return scheduler.run_now_state()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.get("/scheduler/sources")
|
|
195
|
+
def run_sources(config=Depends(get_config_dep)) -> dict:
|
|
196
|
+
"""Watchlists and portfolios a run can cover, with their symbols."""
|
|
197
|
+
return source_catalog(config)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.get("/scheduler/report-options")
|
|
201
|
+
def report_options(config=Depends(get_config_dep)) -> dict:
|
|
202
|
+
"""Everything the Scheduler page needs to build the report settings UI."""
|
|
203
|
+
dispatcher = NotificationDispatcher(config)
|
|
204
|
+
return {
|
|
205
|
+
"languages": [
|
|
206
|
+
{"code": code, "native": native, "english": english, "builtin": code in BUILTIN_LANGUAGES}
|
|
207
|
+
for code, (native, english) in LANGUAGES.items()
|
|
208
|
+
],
|
|
209
|
+
"sections": [
|
|
210
|
+
{"id": sid, "label": label, "description": desc, "default": default,
|
|
211
|
+
# "trader" / "investor": shown only in that area of the app (None = both)
|
|
212
|
+
"area": SECTION_AREAS.get(sid)}
|
|
213
|
+
for sid, label, desc, default in SECTIONS
|
|
214
|
+
],
|
|
215
|
+
"channels": dispatcher.configured_channels("report"),
|
|
216
|
+
"llm_configured": llm_configured(config),
|
|
217
|
+
# Always "analytics": no calls; Smart messaging shows readings
|
|
218
|
+
"compliance": compliance_mode(),
|
|
219
|
+
}
|