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,400 @@
|
|
|
1
|
+
"""Quote/history endpoints - thin wrappers over the existing marketmyna/data fetchers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
8
|
+
|
|
9
|
+
from marketmyna.api.deps import get_config_dep
|
|
10
|
+
from marketmyna.core.constants import Markets, TimeFrame
|
|
11
|
+
from marketmyna.data.china import ChinaAFetcher, HongKongFetcher
|
|
12
|
+
from marketmyna.data.crypto import CryptoDataFetcher
|
|
13
|
+
from marketmyna.data.india import IndiaDataFetcher
|
|
14
|
+
from marketmyna.data.stocks import StockDataFetcher
|
|
15
|
+
from marketmyna.utils.deadline import call_with_deadline
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
_FETCHERS = {
|
|
20
|
+
Markets.US: StockDataFetcher(),
|
|
21
|
+
Markets.GLOBAL: StockDataFetcher(),
|
|
22
|
+
Markets.INDIA: IndiaDataFetcher(),
|
|
23
|
+
Markets.CRYPTO: CryptoDataFetcher(),
|
|
24
|
+
Markets.CHINA_A: ChinaAFetcher(),
|
|
25
|
+
Markets.HONG_KONG: HongKongFetcher(),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_market(market: str) -> Markets:
|
|
30
|
+
try:
|
|
31
|
+
return Markets(market.lower())
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise HTTPException(status_code=400, detail=f"Unknown market '{market}'")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/stocks/{symbol}/quote")
|
|
37
|
+
def get_quote(symbol: str, market: str = Query(default="us")) -> dict:
|
|
38
|
+
fetcher = _FETCHERS[_resolve_market(market)]
|
|
39
|
+
try:
|
|
40
|
+
return call_with_deadline(fetcher.get_quote, 20, symbol)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
raise HTTPException(status_code=502, detail=f"Failed to fetch quote: {exc}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/stocks/{symbol}/history")
|
|
46
|
+
def get_history(
|
|
47
|
+
symbol: str,
|
|
48
|
+
market: str = Query(default="us"),
|
|
49
|
+
period: str = Query(default="1y"),
|
|
50
|
+
interval: str = Query(default=TimeFrame.DAY_1.value),
|
|
51
|
+
) -> list[dict]:
|
|
52
|
+
fetcher = _FETCHERS[_resolve_market(market)]
|
|
53
|
+
try:
|
|
54
|
+
timeframe = TimeFrame(interval)
|
|
55
|
+
except ValueError:
|
|
56
|
+
raise HTTPException(status_code=400, detail=f"Unknown interval '{interval}'")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
df = call_with_deadline(fetcher.get_history, 40, symbol, period=period, interval=timeframe)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise HTTPException(status_code=502, detail=f"Failed to fetch history: {exc}")
|
|
62
|
+
|
|
63
|
+
if df.empty:
|
|
64
|
+
return []
|
|
65
|
+
return df.reset_index().to_dict(orient="records")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/stocks/{symbol}/overview")
|
|
69
|
+
def get_overview(symbol: str, market: str = Query(default="us")) -> dict:
|
|
70
|
+
"""Technicals, trend, returns, risk, fundamentals, analysts, events,
|
|
71
|
+
profile and news for one stock - all computed, no AI call."""
|
|
72
|
+
from marketmyna.services.overview import build_overview
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
return call_with_deadline(build_overview, 60, symbol.strip().upper(), _resolve_market(market).value)
|
|
76
|
+
except TimeoutError as exc:
|
|
77
|
+
raise HTTPException(status_code=504, detail=f"Overview timed out: {exc}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _financials(symbol: str, market: str, period: str) -> dict:
|
|
81
|
+
from marketmyna.services.financials import cached_financials
|
|
82
|
+
|
|
83
|
+
if period not in ("annual", "quarterly"):
|
|
84
|
+
raise HTTPException(status_code=400, detail="period must be 'annual' or 'quarterly'")
|
|
85
|
+
try:
|
|
86
|
+
return call_with_deadline(cached_financials, 60, symbol.strip().upper(), _resolve_market(market).value, period)
|
|
87
|
+
except TimeoutError as exc:
|
|
88
|
+
raise HTTPException(status_code=504, detail=f"Financial statements timed out: {exc}")
|
|
89
|
+
except ValueError as exc:
|
|
90
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise HTTPException(status_code=502, detail=f"Failed to fetch financial statements: {exc}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/stocks/{symbol}/financials")
|
|
96
|
+
def get_financials(symbol: str, market: str = Query(default="us"), period: str = Query(default="annual")) -> dict:
|
|
97
|
+
"""Income statement, balance sheet and cash flow with derived ratios,
|
|
98
|
+
rule-based findings and a plain-English summary - no AI call."""
|
|
99
|
+
return _financials(symbol, market, period)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.post("/stocks/{symbol}/financials/ai-summary")
|
|
103
|
+
def get_financials_ai_summary(
|
|
104
|
+
symbol: str,
|
|
105
|
+
market: str = Query(default="us"),
|
|
106
|
+
period: str = Query(default="annual"),
|
|
107
|
+
config=Depends(get_config_dep),
|
|
108
|
+
) -> dict:
|
|
109
|
+
"""AI analyst narrative over the three statements (uses the configured LLM)."""
|
|
110
|
+
from marketmyna.llm.channels import llm_configured
|
|
111
|
+
from marketmyna.services.financials import ai_summary
|
|
112
|
+
|
|
113
|
+
if not llm_configured(config):
|
|
114
|
+
raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to get an AI summary.")
|
|
115
|
+
data = _financials(symbol, market, period)
|
|
116
|
+
text = call_with_deadline(ai_summary, 120, data, config)
|
|
117
|
+
if not text:
|
|
118
|
+
raise HTTPException(status_code=502, detail="The AI model did not return a summary. Try again or check Settings.")
|
|
119
|
+
return {"symbol": data["symbol"], "period": data["period"], "summary": text}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.get("/stocks/{symbol}/shariah")
|
|
123
|
+
def get_shariah(symbol: str, market: str = Query(default="us")) -> dict:
|
|
124
|
+
"""Shariah screen: business activity, debt / cash / receivables ratios
|
|
125
|
+
and interest income (S&P + AAOIFI limits), plus purification - no AI."""
|
|
126
|
+
from marketmyna.services.shariah import build_shariah_profile
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return call_with_deadline(build_shariah_profile, 60, symbol.strip().upper(), _resolve_market(market).value)
|
|
130
|
+
except TimeoutError as exc:
|
|
131
|
+
raise HTTPException(status_code=504, detail=f"Ethical screen timed out: {exc}")
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
raise HTTPException(status_code=502, detail=f"Failed to run the ethical screen: {exc}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _leadership(symbol: str, market: str) -> dict:
|
|
139
|
+
from marketmyna.services.leadership import build_leadership
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return call_with_deadline(build_leadership, 45, symbol.strip().upper(), _resolve_market(market).value)
|
|
143
|
+
except TimeoutError as exc:
|
|
144
|
+
raise HTTPException(status_code=504, detail=f"Leadership data timed out: {exc}")
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
raise HTTPException(status_code=502, detail=f"Failed to fetch leadership data: {exc}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.get("/stocks/{symbol}/leadership")
|
|
152
|
+
def get_leadership(symbol: str, market: str = Query(default="us")) -> dict:
|
|
153
|
+
"""CEO, other officers and governance risk scores - filing data, no AI."""
|
|
154
|
+
return _leadership(symbol, market)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.get("/stocks/{symbol}/ownership")
|
|
158
|
+
def get_ownership(symbol: str, market: str = Query(default="us")) -> dict:
|
|
159
|
+
"""Insider / institution split, top holders, insider trades, short interest."""
|
|
160
|
+
from marketmyna.services.ownership import build
|
|
161
|
+
|
|
162
|
+
return build(symbol.upper(), market.lower())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.post("/stocks/{symbol}/mdna/ai-summary")
|
|
166
|
+
def get_mdna_summary(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
|
|
167
|
+
"""AI summary of the MD&A section of the latest 10-K / 10-Q (US SEC filers)."""
|
|
168
|
+
from marketmyna.llm.channels import llm_configured
|
|
169
|
+
from marketmyna.services.sec_mdna import SecNotConfigured, ai_summary
|
|
170
|
+
|
|
171
|
+
if market.lower() != "us":
|
|
172
|
+
raise HTTPException(status_code=400, detail="Management discussion summaries are for US (SEC) filers only")
|
|
173
|
+
if not llm_configured(config):
|
|
174
|
+
raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to summarise filings.")
|
|
175
|
+
try:
|
|
176
|
+
return ai_summary(symbol.upper(), config)
|
|
177
|
+
except SecNotConfigured as exc:
|
|
178
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
raise HTTPException(status_code=502, detail=f"Filing summary unavailable: {exc}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.post("/stocks/{symbol}/leadership/ai-brief")
|
|
186
|
+
def get_leadership_ai_brief(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
|
|
187
|
+
"""AI brief on the CEO: introduction, achievements, pitfalls, verdict."""
|
|
188
|
+
from marketmyna.data.news_search import stock_news
|
|
189
|
+
from marketmyna.llm.channels import llm_configured
|
|
190
|
+
from marketmyna.services.leadership import ai_ceo_brief
|
|
191
|
+
|
|
192
|
+
if not llm_configured(config):
|
|
193
|
+
raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to get a CEO brief.")
|
|
194
|
+
data = _leadership(symbol, market)
|
|
195
|
+
try:
|
|
196
|
+
news = stock_news(data["symbol"], data["market"], limit=10, name=data.get("name"))
|
|
197
|
+
headlines = [n["title"] for n in news if n.get("title")]
|
|
198
|
+
except Exception:
|
|
199
|
+
headlines = []
|
|
200
|
+
brief = call_with_deadline(ai_ceo_brief, 120, data, config, headlines)
|
|
201
|
+
if not brief:
|
|
202
|
+
raise HTTPException(status_code=502, detail="The AI model did not return a usable brief. Try again or check Settings.")
|
|
203
|
+
return {"symbol": data["symbol"], "ceo": data["ceo"]["name"], **brief}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.get("/compare")
|
|
207
|
+
def compare_stocks(
|
|
208
|
+
s: str = Query(description="Comma-separated SYMBOL:market pairs, e.g. AAPL:us,RELIANCE:india"),
|
|
209
|
+
period: str = Query(default="1y", description="6mo | 1y | 3y | 5y"),
|
|
210
|
+
) -> dict:
|
|
211
|
+
"""2-5 instruments side by side: rebased prices, returns, risk, fundamentals, correlation."""
|
|
212
|
+
from marketmyna.services.compare import compare
|
|
213
|
+
|
|
214
|
+
items = []
|
|
215
|
+
for part in s.split(","):
|
|
216
|
+
if not part.strip():
|
|
217
|
+
continue
|
|
218
|
+
sym, _, mkt = part.partition(":")
|
|
219
|
+
mkt = (mkt or "us").lower()
|
|
220
|
+
_resolve_market(mkt)
|
|
221
|
+
items.append((sym, mkt))
|
|
222
|
+
try:
|
|
223
|
+
return call_with_deadline(compare, 60, items, period)
|
|
224
|
+
except ValueError as exc:
|
|
225
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.get("/stocks/{symbol}/peers")
|
|
229
|
+
def get_peers(symbol: str, market: str = Query(default="us"), limit: int = Query(default=6, ge=2, le=10)) -> dict:
|
|
230
|
+
"""Same-market companies in the same industry (or sector), closest in size."""
|
|
231
|
+
from marketmyna.services.compare import peers
|
|
232
|
+
|
|
233
|
+
_resolve_market(market)
|
|
234
|
+
return call_with_deadline(peers, 60, symbol, market, limit)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@router.get("/stocks/{symbol}/valuation")
|
|
238
|
+
def get_valuation(symbol: str, market: str = Query(default="us")) -> dict:
|
|
239
|
+
"""Valuation meter: P/E, P/B and P/S against the sector median of the
|
|
240
|
+
market's largest companies, plus a growth-at-a-reasonable-price check."""
|
|
241
|
+
from marketmyna.services.valuation import meter
|
|
242
|
+
|
|
243
|
+
_resolve_market(market)
|
|
244
|
+
return call_with_deadline(meter, 90, symbol.upper(), market.lower())
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@router.get("/stocks/{symbol}/dcf")
|
|
248
|
+
def get_dcf(symbol: str, market: str = Query(default="us")) -> dict:
|
|
249
|
+
"""Discounted cash flow with its default assumptions, reverse DCF (growth
|
|
250
|
+
the price implies) and the Graham number. The page recomputes the DCF
|
|
251
|
+
as the viewer edits the assumptions."""
|
|
252
|
+
from marketmyna.services.dcf import build
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
return call_with_deadline(build, 90, symbol.strip().upper(), _resolve_market(market).value)
|
|
256
|
+
except TimeoutError as exc:
|
|
257
|
+
raise HTTPException(status_code=504, detail=f"DCF timed out: {exc}")
|
|
258
|
+
except ValueError as exc:
|
|
259
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.get("/stocks/{symbol}/checklists")
|
|
263
|
+
def get_checklists(symbol: str, market: str = Query(default="us")) -> dict:
|
|
264
|
+
"""Graham, Buffett-style, Lynch, Greenblatt and Marks-style rules, each pass / fail with its number."""
|
|
265
|
+
from marketmyna.services.investor_lenses import build
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
return call_with_deadline(build, 90, symbol.strip().upper(), _resolve_market(market).value)
|
|
269
|
+
except TimeoutError as exc:
|
|
270
|
+
raise HTTPException(status_code=504, detail=f"Checklists timed out: {exc}")
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@router.get("/stocks/{symbol}/estimates")
|
|
276
|
+
def get_estimates(symbol: str, market: str = Query(default="us")) -> dict:
|
|
277
|
+
"""Consensus EPS / revenue, revisions over 90 days, and recent beats or misses."""
|
|
278
|
+
from marketmyna.services.analysis_run import yahoo_symbol
|
|
279
|
+
from marketmyna.services.estimates import build
|
|
280
|
+
|
|
281
|
+
_resolve_market(market)
|
|
282
|
+
if market.lower() == "crypto":
|
|
283
|
+
return {"available": False, "periods": [], "history": []}
|
|
284
|
+
return call_with_deadline(build, 45, yahoo_symbol(symbol, market.lower()))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.get("/stocks/{symbol}/dcf/model.xlsx")
|
|
288
|
+
def get_dcf_model(symbol: str, market: str = Query(default="us"),
|
|
289
|
+
growth: Optional[float] = Query(default=None, ge=-0.5, le=1.0),
|
|
290
|
+
discount: Optional[float] = Query(default=None, gt=0, le=1.0),
|
|
291
|
+
terminal: Optional[float] = Query(default=None, ge=-0.05, le=0.2)) -> Response:
|
|
292
|
+
"""The DCF as an Excel workbook with live formulas and a sensitivity grid,
|
|
293
|
+
at the rates the viewer chose on the page (defaults otherwise)."""
|
|
294
|
+
from marketmyna.services import dcf, dcf_model
|
|
295
|
+
|
|
296
|
+
sym = symbol.strip().upper()
|
|
297
|
+
try:
|
|
298
|
+
data = call_with_deadline(dcf.build, 90, sym, _resolve_market(market).value)
|
|
299
|
+
body = dcf_model.build(data, growth, discount, terminal)
|
|
300
|
+
except TimeoutError as exc:
|
|
301
|
+
raise HTTPException(status_code=504, detail=f"DCF timed out: {exc}")
|
|
302
|
+
except ValueError as exc:
|
|
303
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
304
|
+
return Response(body, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
305
|
+
headers={"Content-Disposition": f'attachment; filename="{sym}-dcf-model.xlsx"'})
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@router.get("/stocks/{symbol}/earnings")
|
|
309
|
+
def get_earnings(symbol: str, market: str = Query(default="us")) -> dict:
|
|
310
|
+
"""Earnings preview (next date, consensus, past reactions, options-implied
|
|
311
|
+
move, what to watch) and review (actual vs estimate, price reaction)."""
|
|
312
|
+
from marketmyna.services.earnings import build
|
|
313
|
+
|
|
314
|
+
_resolve_market(market)
|
|
315
|
+
if market.lower() == "crypto":
|
|
316
|
+
return {"available": False, "history": []}
|
|
317
|
+
return call_with_deadline(build, 60, symbol.upper(), market.lower())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@router.get("/etf/{symbol}")
|
|
321
|
+
def etf_lookthrough(symbol: str, market: str = Query(default="us")) -> dict:
|
|
322
|
+
"""ETF holdings, sector mix and costs; is_etf=false for anything else."""
|
|
323
|
+
from marketmyna.services.etf import lookthrough
|
|
324
|
+
|
|
325
|
+
_resolve_market(market)
|
|
326
|
+
out = call_with_deadline(lookthrough, 45, symbol, market)
|
|
327
|
+
out.pop("holdings", None) # full list is only needed for overlap
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@router.get("/etf-overlap")
|
|
332
|
+
def etf_overlap(a: str, b: str, market_a: str = Query(default="us"), market_b: Optional[str] = None) -> dict:
|
|
333
|
+
"""How much of two ETFs' weight sits in the same stocks."""
|
|
334
|
+
from marketmyna.services.etf import lookthrough, overlap
|
|
335
|
+
|
|
336
|
+
market_b = market_b or market_a
|
|
337
|
+
_resolve_market(market_a)
|
|
338
|
+
_resolve_market(market_b)
|
|
339
|
+
x = call_with_deadline(lookthrough, 45, a, market_a)
|
|
340
|
+
y = call_with_deadline(lookthrough, 45, b, market_b)
|
|
341
|
+
if not (x.get("status") == "ok" and y.get("status") == "ok"):
|
|
342
|
+
return {"a": a.upper(), "b": b.upper(), "available": False}
|
|
343
|
+
return {"available": True, **overlap(x, y)}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@router.get("/stocks/{symbol}/dividends")
|
|
347
|
+
def get_dividends(symbol: str, market: str = Query(default="us")) -> dict:
|
|
348
|
+
"""Payments, yearly totals, trailing yield, 5-year growth and years without a cut."""
|
|
349
|
+
from marketmyna.services.analysis_run import yahoo_symbol
|
|
350
|
+
from marketmyna.services.dividends import build
|
|
351
|
+
|
|
352
|
+
_resolve_market(market)
|
|
353
|
+
if market.lower() == "crypto":
|
|
354
|
+
return {"pays": False}
|
|
355
|
+
return call_with_deadline(build, 45, yahoo_symbol(symbol, market.lower()))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _social(symbol: str, market: str, config, refresh: bool = False) -> dict:
|
|
359
|
+
from marketmyna.api.deps import get_database_manager
|
|
360
|
+
from marketmyna.services import social_sentiment
|
|
361
|
+
|
|
362
|
+
market = _resolve_market(market).value
|
|
363
|
+
try:
|
|
364
|
+
db = get_database_manager()
|
|
365
|
+
except Exception:
|
|
366
|
+
db = None
|
|
367
|
+
try:
|
|
368
|
+
return call_with_deadline(social_sentiment.build, 150, symbol, market, config, db, refresh)
|
|
369
|
+
except TimeoutError as exc:
|
|
370
|
+
raise HTTPException(status_code=504, detail=f"Social sentiment timed out: {exc}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@router.get("/stocks/{symbol}/social")
|
|
374
|
+
def get_social_sentiment(
|
|
375
|
+
symbol: str,
|
|
376
|
+
market: str = Query(default="us"),
|
|
377
|
+
refresh: bool = Query(default=False),
|
|
378
|
+
config=Depends(get_config_dep),
|
|
379
|
+
) -> dict:
|
|
380
|
+
"""What people say on Reddit / StockTwits / ValuePickr / Hacker News:
|
|
381
|
+
score, bull/bear split, themes, timeline and top posts. Lexicon-scored,
|
|
382
|
+
no AI; cached 20 minutes per stock."""
|
|
383
|
+
return _social(symbol, market, config, refresh)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@router.post("/stocks/{symbol}/social/ai-summary")
|
|
387
|
+
def get_social_ai_summary(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
|
|
388
|
+
"""AI digest of the chatter: summary, bull and bear arguments, hype risk."""
|
|
389
|
+
from marketmyna.llm.channels import llm_configured
|
|
390
|
+
from marketmyna.services.social_sentiment import ai_summary
|
|
391
|
+
|
|
392
|
+
if not llm_configured(config):
|
|
393
|
+
raise HTTPException(status_code=409, detail="No AI model configured. Add one in Settings to summarise the chatter.")
|
|
394
|
+
data = _social(symbol, market, config)
|
|
395
|
+
if not data["posts"]:
|
|
396
|
+
raise HTTPException(status_code=404, detail="No recent posts to summarise.")
|
|
397
|
+
summary = call_with_deadline(ai_summary, 120, data, config)
|
|
398
|
+
if not summary:
|
|
399
|
+
raise HTTPException(status_code=502, detail="The AI model did not return a usable summary. Try again or check Settings.")
|
|
400
|
+
return {"symbol": data["symbol"], "posts": data["posts"], **summary}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Notification-channel settings over HTTP - an API face on the existing
|
|
2
|
+
marketmyna/notifiers system. Field list/kinds come from
|
|
3
|
+
marketmyna/notifiers/settings_schema.py, the single source of truth. AI keys are
|
|
4
|
+
under /api/auth/account/api-keys; data-source keys in data_sources.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
from typing import Any, List, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
13
|
+
from pydantic import BaseModel, Field, create_model
|
|
14
|
+
|
|
15
|
+
from marketmyna.api.deps import get_config_dep
|
|
16
|
+
from marketmyna.api.middlewares.auth import require_admin
|
|
17
|
+
from marketmyna.notifiers.dispatcher import NotificationDispatcher
|
|
18
|
+
from marketmyna.notifiers.settings_schema import CHANNELS, is_secret_kind, iter_fields
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
# kind -> the HTTP-facing Python type for that field. List fields are taken
|
|
23
|
+
# as a native JSON array here (not a comma-string - HTTP payloads are
|
|
24
|
+
# natively structured, so there's no reason to make the client stuff a list
|
|
25
|
+
# into one string).
|
|
26
|
+
_KIND_TYPES = {
|
|
27
|
+
"text": Optional[str],
|
|
28
|
+
"password": Optional[str],
|
|
29
|
+
"multiline": Optional[str],
|
|
30
|
+
"int": Optional[int],
|
|
31
|
+
"float": Optional[float],
|
|
32
|
+
"bool": Optional[bool],
|
|
33
|
+
"list": Optional[List[str]],
|
|
34
|
+
"secret_list": Optional[List[str]], # list of secrets, never echoed back
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# One flat pydantic model covering every channel attr, built from the same
|
|
38
|
+
# CHANNELS list the dialog uses, so the two surfaces can't drift apart.
|
|
39
|
+
NotificationSettingsUpdate = create_model(
|
|
40
|
+
"NotificationSettingsUpdate",
|
|
41
|
+
**{
|
|
42
|
+
attr: (_KIND_TYPES[kind], Field(default=None))
|
|
43
|
+
for _channel, attr, _label, kind in iter_fields()
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_telegram_ids(overrides: dict, config) -> None:
|
|
50
|
+
"""The commonest Telegram mistake: pasting the bot's own number (the part
|
|
51
|
+
of the token before ':') as the Chat ID. Bots can't message themselves."""
|
|
52
|
+
token = overrides.get("telegram_bot_token") or getattr(config, "telegram_bot_token", None) or ""
|
|
53
|
+
chat_ids = overrides.get("telegram_chat_id", getattr(config, "telegram_chat_id", None)) or ""
|
|
54
|
+
bot_number = str(token).split(":")[0]
|
|
55
|
+
if bot_number and bot_number in [c.strip() for c in str(chat_ids).split(",")]:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=400,
|
|
58
|
+
detail=(f"Chat ID {bot_number} is your bot's own number (from the token), not your chat. "
|
|
59
|
+
"Open your bot in Telegram, send /start, and paste the chat ID it replies with."),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/system-config/notifications", dependencies=[Depends(require_admin)])
|
|
64
|
+
def get_notification_settings(config=Depends(get_config_dep)) -> dict[str, Any]:
|
|
65
|
+
"""Current notification config, grouped by channel. Secret fields are
|
|
66
|
+
reported as {"is_set": bool}, never as their raw value."""
|
|
67
|
+
result: dict[str, dict[str, Any]] = {}
|
|
68
|
+
for channel_name, fields in CHANNELS:
|
|
69
|
+
channel_out: dict[str, Any] = {}
|
|
70
|
+
for attr, _label, kind in fields:
|
|
71
|
+
value = getattr(config, attr, None)
|
|
72
|
+
if is_secret_kind(kind):
|
|
73
|
+
channel_out[attr] = {"is_set": bool(value)}
|
|
74
|
+
else:
|
|
75
|
+
channel_out[attr] = value
|
|
76
|
+
result[channel_name] = channel_out
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.put("/system-config/notifications", dependencies=[Depends(require_admin)])
|
|
81
|
+
def update_notification_settings(
|
|
82
|
+
body: NotificationSettingsUpdate, config=Depends(get_config_dep) # type: ignore[valid-type]
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Partial update: only fields explicitly present in the request body change."""
|
|
85
|
+
overrides = body.model_dump(exclude_unset=True)
|
|
86
|
+
_check_telegram_ids(overrides, config)
|
|
87
|
+
for attr, value in overrides.items():
|
|
88
|
+
setattr(config, attr, value)
|
|
89
|
+
config.save()
|
|
90
|
+
return get_notification_settings(config=config)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.post("/system-config/notifications/test", dependencies=[Depends(require_admin)])
|
|
94
|
+
def test_notification_settings(
|
|
95
|
+
body: NotificationSettingsUpdate, config=Depends(get_config_dep) # type: ignore[valid-type]
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Fire a live test message through every channel that would be
|
|
98
|
+
configured once `body`'s overrides are applied - without saving them,
|
|
99
|
+
mirroring SettingsDialog._on_test."""
|
|
100
|
+
overrides = body.model_dump(exclude_unset=True)
|
|
101
|
+
_check_telegram_ids(overrides, config)
|
|
102
|
+
temp_config = dataclasses.replace(config, **overrides)
|
|
103
|
+
dispatcher = NotificationDispatcher(temp_config)
|
|
104
|
+
|
|
105
|
+
configured = dispatcher.configured_channels()
|
|
106
|
+
if not configured:
|
|
107
|
+
return {
|
|
108
|
+
"configured_channels": [],
|
|
109
|
+
"results": {},
|
|
110
|
+
"message": "No channel is fully configured yet - fill in a channel's fields first.",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
results = dispatcher.send_all(
|
|
114
|
+
"This is a test notification from MarketMyna settings.",
|
|
115
|
+
title="MarketMyna Test",
|
|
116
|
+
)
|
|
117
|
+
return {"configured_channels": configured, "results": results}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_ROUTING = [
|
|
121
|
+
("report", "notification_report_channels", "Daily reports", "Scheduled analysis reports"),
|
|
122
|
+
("alert", "notification_alert_channels", "Alerts", "Price and portfolio alerts"),
|
|
123
|
+
("system_error", "notification_system_error_channels", "System errors", "A scheduled run that failed completely"),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NotificationRoutingUpdate(BaseModel):
|
|
128
|
+
report: Optional[List[str]] = None
|
|
129
|
+
alert: Optional[List[str]] = None
|
|
130
|
+
system_error: Optional[List[str]] = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.get("/system-config/notification-routing", dependencies=[Depends(require_admin)])
|
|
134
|
+
def get_notification_routing(config=Depends(get_config_dep)) -> dict[str, Any]:
|
|
135
|
+
"""Which configured channels receive each kind of message. An empty
|
|
136
|
+
list means "every configured channel"."""
|
|
137
|
+
dispatcher = NotificationDispatcher(config)
|
|
138
|
+
return {
|
|
139
|
+
"channels": dispatcher.configured_channels(),
|
|
140
|
+
"purposes": [{"id": pid, "label": label, "description": desc} for pid, _attr, label, desc in _ROUTING],
|
|
141
|
+
"routes": {pid: list(getattr(config, attr, None) or []) for pid, attr, _l, _d in _ROUTING},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.put("/system-config/notification-routing", dependencies=[Depends(require_admin)])
|
|
146
|
+
def update_notification_routing(body: NotificationRoutingUpdate, config=Depends(get_config_dep)) -> dict[str, Any]:
|
|
147
|
+
known = set(NotificationDispatcher(config).channel_names())
|
|
148
|
+
for pid, attr, _label, _desc in _ROUTING:
|
|
149
|
+
value = getattr(body, pid)
|
|
150
|
+
if value is None:
|
|
151
|
+
continue
|
|
152
|
+
unknown = [c for c in value if c not in known]
|
|
153
|
+
if unknown:
|
|
154
|
+
raise HTTPException(status_code=400, detail=f"Unknown channel '{unknown[0]}'")
|
|
155
|
+
setattr(config, attr, list(dict.fromkeys(value)) or None)
|
|
156
|
+
config.save()
|
|
157
|
+
return get_notification_routing(config=config)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Thesis tracker (marketmyna/services/thesis.py): one thesis per stock, its update
|
|
2
|
+
log, and a check against live data."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from marketmyna.api.deps import get_config_dep
|
|
12
|
+
from marketmyna.utils.deadline import call_with_deadline
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Pillar(BaseModel):
|
|
18
|
+
text: str = Field(max_length=300)
|
|
19
|
+
status: str = "on_track"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ThesisIn(BaseModel):
|
|
23
|
+
symbol: str = Field(min_length=1, max_length=40)
|
|
24
|
+
market: str = Field(min_length=1, max_length=32)
|
|
25
|
+
statement: Optional[str] = Field(default=None, max_length=2000)
|
|
26
|
+
pillars: Optional[list[Pillar]] = Field(default=None, max_length=10)
|
|
27
|
+
risks: Optional[list[str]] = Field(default=None, max_length=10)
|
|
28
|
+
exit_below: Optional[float] = None
|
|
29
|
+
exit_note: Optional[str] = Field(default=None, max_length=500)
|
|
30
|
+
target: Optional[float] = None
|
|
31
|
+
conviction: Optional[str] = None
|
|
32
|
+
clear_exit: bool = False
|
|
33
|
+
clear_target: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LogIn(BaseModel):
|
|
37
|
+
note: str = Field(min_length=1, max_length=1000)
|
|
38
|
+
impact: str
|
|
39
|
+
conviction: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _key(symbol: str, market: str) -> tuple[str, str]:
|
|
43
|
+
market = market.lower()
|
|
44
|
+
return (symbol.strip() if market == "mf" else symbol.strip().upper()), market
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/theses")
|
|
48
|
+
def list_theses(check: bool = Query(default=False), config=Depends(get_config_dep)) -> list[dict]:
|
|
49
|
+
"""Every thesis; with check=true each also gets its live data check."""
|
|
50
|
+
from marketmyna.services import thesis
|
|
51
|
+
|
|
52
|
+
rows = thesis.list_all(config)
|
|
53
|
+
if not check:
|
|
54
|
+
return rows
|
|
55
|
+
return call_with_deadline(lambda: [thesis.with_check(t) for t in rows], 120)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/theses/{symbol}")
|
|
59
|
+
def get_thesis(symbol: str, market: str = Query(default="us"), config=Depends(get_config_dep)) -> dict:
|
|
60
|
+
from marketmyna.services import thesis
|
|
61
|
+
|
|
62
|
+
t = thesis.get(config, *_key(symbol, market))
|
|
63
|
+
if t is None:
|
|
64
|
+
return {"thesis": None}
|
|
65
|
+
return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.put("/theses")
|
|
69
|
+
def save_thesis(body: ThesisIn, config=Depends(get_config_dep)) -> dict:
|
|
70
|
+
from marketmyna.services import thesis
|
|
71
|
+
|
|
72
|
+
fields = body.model_dump(exclude_none=True, exclude={"symbol", "market", "clear_exit", "clear_target"})
|
|
73
|
+
if body.clear_exit:
|
|
74
|
+
fields["exit_below"] = None
|
|
75
|
+
if body.clear_target:
|
|
76
|
+
fields["target"] = None
|
|
77
|
+
try:
|
|
78
|
+
t = thesis.save(config, *_key(body.symbol, body.market), **fields)
|
|
79
|
+
except thesis.ThesisError as exc:
|
|
80
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
81
|
+
return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.post("/theses/{thesis_id}/log")
|
|
85
|
+
def add_log(thesis_id: int, body: LogIn, config=Depends(get_config_dep)) -> dict:
|
|
86
|
+
from marketmyna.services import thesis
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
t = thesis.add_log(config, thesis_id, body.note, body.impact, body.conviction)
|
|
90
|
+
except thesis.ThesisError as exc:
|
|
91
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
92
|
+
if t is None:
|
|
93
|
+
raise HTTPException(status_code=404, detail="Thesis not found")
|
|
94
|
+
return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/theses/{thesis_id}/reviewed")
|
|
98
|
+
def reviewed(thesis_id: int, config=Depends(get_config_dep)) -> dict:
|
|
99
|
+
from marketmyna.services import thesis
|
|
100
|
+
|
|
101
|
+
t = thesis.mark_reviewed(config, thesis_id)
|
|
102
|
+
if t is None:
|
|
103
|
+
raise HTTPException(status_code=404, detail="Thesis not found")
|
|
104
|
+
return {"thesis": call_with_deadline(thesis.with_check, 60, t)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.delete("/theses/{thesis_id}", status_code=204)
|
|
108
|
+
def delete_thesis(thesis_id: int, config=Depends(get_config_dep)) -> None:
|
|
109
|
+
from marketmyna.services import thesis
|
|
110
|
+
|
|
111
|
+
if not thesis.delete(config, thesis_id):
|
|
112
|
+
raise HTTPException(status_code=404, detail="Thesis not found")
|