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,116 @@
|
|
|
1
|
+
"""Single-user build: there is no login. Every request runs as the one
|
|
2
|
+
local owner, an admin account made on first use (accounts.ensure_local_owner),
|
|
3
|
+
so the per-user data scoping underneath works unchanged. Run it only where
|
|
4
|
+
you alone can reach it (localhost, a VPN, or behind your own proxy auth).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
from fastapi import APIRouter, Request
|
|
15
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
16
|
+
|
|
17
|
+
from marketmyna.core import tenant
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
OPEN_PATHS = ("/health", "/api/health", "/api/auth/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def accounts_config():
|
|
25
|
+
"""The app Config the owner lookup uses. Tests patch this."""
|
|
26
|
+
from marketmyna.api.deps import get_config_dep
|
|
27
|
+
|
|
28
|
+
return get_config_dep()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def accounts_db(config):
|
|
32
|
+
from marketmyna.storage import DatabaseManager, get_db_url
|
|
33
|
+
|
|
34
|
+
return DatabaseManager.instance(get_db_url(config=config))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_owner_ids: dict[str, int] = {}
|
|
38
|
+
_owner_lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def local_owner_id(config) -> int:
|
|
42
|
+
"""Id of the local owner account, made on first use (cached per database)."""
|
|
43
|
+
from marketmyna.storage import get_db_url
|
|
44
|
+
|
|
45
|
+
url = get_db_url(config=config)
|
|
46
|
+
with _owner_lock:
|
|
47
|
+
if url not in _owner_ids:
|
|
48
|
+
from marketmyna.services.accounts import ensure_local_owner
|
|
49
|
+
|
|
50
|
+
with accounts_db(config).get_session() as session, tenant.system_scope():
|
|
51
|
+
_owner_ids[url] = ensure_local_owner(session).id
|
|
52
|
+
return _owner_ids[url]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _session_user(config, raw: str | None):
|
|
56
|
+
"""(user_id, role): always the local owner."""
|
|
57
|
+
return local_owner_id(config), "admin"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def current_user_from_request(request: Request, session):
|
|
61
|
+
"""The local owner's UserRecord (in the caller's DB session)."""
|
|
62
|
+
from marketmyna.storage import UserRecord
|
|
63
|
+
|
|
64
|
+
return session.get(UserRecord, local_owner_id(accounts_config()))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def api_docs_enabled() -> bool:
|
|
68
|
+
"""/docs, /redoc and /openapi.json exist only with API_DOCS=1."""
|
|
69
|
+
return os.environ.get("API_DOCS", "").strip() == "1"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_auth_config() -> None:
|
|
73
|
+
"""Turns per-user scoping on (every request runs as the local owner)."""
|
|
74
|
+
from marketmyna.api.deps import get_config_dep
|
|
75
|
+
|
|
76
|
+
get_config_dep().accounts_enabled = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_authenticated(scope: Scope) -> bool:
|
|
80
|
+
"""Always true: the request runs as the local owner."""
|
|
81
|
+
state = scope.setdefault("state", {})
|
|
82
|
+
state["user_id"], state["user_role"] = _session_user(accounts_config(), None)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_admin(request: Request) -> None:
|
|
87
|
+
"""Dependency kept on admin routes (AI configuration, integrations). The
|
|
88
|
+
local owner is the admin, so it always passes."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AuthMiddleware:
|
|
92
|
+
"""Puts every request in the owner's data scope. Pure ASGI (not
|
|
93
|
+
BaseHTTPMiddleware) so SSE streams pass through untouched."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
96
|
+
self.app = app
|
|
97
|
+
|
|
98
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
99
|
+
if scope["type"] not in ("http", "websocket") or scope.get("path", "").startswith(OPEN_PATHS):
|
|
100
|
+
await self.app(scope, receive, send)
|
|
101
|
+
return
|
|
102
|
+
# The owner lookup hits the database on first use: keep it off the event loop.
|
|
103
|
+
await anyio.to_thread.run_sync(is_authenticated, scope)
|
|
104
|
+
token = tenant.set_scope(scope["state"]["user_id"])
|
|
105
|
+
try:
|
|
106
|
+
await self.app(scope, receive, send)
|
|
107
|
+
finally:
|
|
108
|
+
tenant.reset_scope(token)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
router = APIRouter()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/status")
|
|
115
|
+
def auth_status(request: Request) -> dict:
|
|
116
|
+
return {"authenticated": is_authenticated(request.scope)}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Refuse cross-site writes to the API (CSRF defence in depth).
|
|
2
|
+
|
|
3
|
+
The session is an HttpOnly cookie (marketmyna/api/middlewares/auth.py) with
|
|
4
|
+
SameSite=Lax, which already stops other sites' forms and fetches from
|
|
5
|
+
sending it on POST. This adds a second, header-based check so a cookie
|
|
6
|
+
policy change, an old browser or a sibling subdomain can't be used to make
|
|
7
|
+
a signed-in user's browser change their account, alerts or keys.
|
|
8
|
+
|
|
9
|
+
For POST / PUT / PATCH / DELETE under /api/:
|
|
10
|
+
- Sec-Fetch-Site (sent by every current browser) must be "same-origin" or
|
|
11
|
+
"none"; anything else needs an Origin on the allowed list.
|
|
12
|
+
- Without Sec-Fetch-Site, an Origin header, if present, must match the
|
|
13
|
+
request's own host or the allowed list.
|
|
14
|
+
- No browser headers at all (server-to-server, tests, curl) passes: that
|
|
15
|
+
caller has no victim's cookie to ride on.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
29
|
+
SKIP_PREFIXES: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CSRFMiddleware:
|
|
33
|
+
def __init__(self, app: ASGIApp, allowed_origins: list[str] | None = None) -> None:
|
|
34
|
+
self.app = app
|
|
35
|
+
self.allowed = {o.rstrip("/").lower() for o in (allowed_origins or [])}
|
|
36
|
+
|
|
37
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
38
|
+
if scope["type"] == "http" and not self._allowed(scope):
|
|
39
|
+
logger.warning("CSRF: refused %s %s", scope.get("method"), scope.get("path"))
|
|
40
|
+
body = json.dumps({"detail": "Cross-site request refused"}).encode()
|
|
41
|
+
await send({"type": "http.response.start", "status": 403,
|
|
42
|
+
"headers": [(b"content-type", b"application/json"),
|
|
43
|
+
(b"content-length", str(len(body)).encode())]})
|
|
44
|
+
await send({"type": "http.response.body", "body": body})
|
|
45
|
+
return
|
|
46
|
+
await self.app(scope, receive, send)
|
|
47
|
+
|
|
48
|
+
def _allowed(self, scope: Scope) -> bool:
|
|
49
|
+
path = scope.get("path", "")
|
|
50
|
+
if scope.get("method") not in UNSAFE_METHODS or not path.startswith("/api/"):
|
|
51
|
+
return True
|
|
52
|
+
if path.startswith(SKIP_PREFIXES):
|
|
53
|
+
return True
|
|
54
|
+
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
|
55
|
+
origin = (headers.get("origin") or "").rstrip("/").lower()
|
|
56
|
+
fetch_site = (headers.get("sec-fetch-site") or "").lower()
|
|
57
|
+
if fetch_site:
|
|
58
|
+
return fetch_site in ("same-origin", "none") or (origin != "" and origin in self.allowed)
|
|
59
|
+
if not origin or origin == "null":
|
|
60
|
+
# Some privacy setups send "Origin: null"; without Sec-Fetch-Site
|
|
61
|
+
# there's nothing better to go on, and SameSite=Lax still applies.
|
|
62
|
+
return True
|
|
63
|
+
if origin in self.allowed:
|
|
64
|
+
return True
|
|
65
|
+
host = (headers.get("x-forwarded-host") or headers.get("host") or "").split(",")[0].strip().lower()
|
|
66
|
+
return urlsplit(origin).netloc == host
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Uniform error envelope for the API: {"error", "message", "detail"}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_error_handlers(app: FastAPI) -> None:
|
|
15
|
+
@app.exception_handler(HTTPException)
|
|
16
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
17
|
+
return JSONResponse(
|
|
18
|
+
status_code=exc.status_code,
|
|
19
|
+
content={"error": "http_error", "message": str(exc.detail), "detail": None},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@app.exception_handler(RequestValidationError)
|
|
23
|
+
async def validation_exception_handler(
|
|
24
|
+
request: Request, exc: RequestValidationError
|
|
25
|
+
) -> JSONResponse:
|
|
26
|
+
return JSONResponse(
|
|
27
|
+
status_code=422,
|
|
28
|
+
content={"error": "validation_error", "message": "Invalid request", "detail": exc.errors()},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@app.exception_handler(Exception)
|
|
32
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
33
|
+
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
|
34
|
+
detail = str(exc) if logger.isEnabledFor(logging.DEBUG) else None
|
|
35
|
+
return JSONResponse(
|
|
36
|
+
status_code=500,
|
|
37
|
+
content={"error": "internal_error", "message": "Internal server error", "detail": detail},
|
|
38
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Treat /api/v1/foo/ the same as /api/v1/foo instead of redirecting.
|
|
2
|
+
|
|
3
|
+
FastAPI's default answer to a trailing-slash mismatch is a 307 to an
|
|
4
|
+
absolute URL built from the Host header and scheme it sees, which behind a
|
|
5
|
+
proxy can be the internal address. Stripping the slash before routing
|
|
6
|
+
avoids the redirect (and its extra round trip) entirely.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StripTrailingSlashMiddleware:
|
|
15
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
16
|
+
self.app = app
|
|
17
|
+
|
|
18
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
19
|
+
if scope["type"] in ("http", "websocket"):
|
|
20
|
+
path = scope.get("path", "")
|
|
21
|
+
if len(path) > 1 and path.endswith("/"):
|
|
22
|
+
scope = dict(scope)
|
|
23
|
+
scope["path"] = path.rstrip("/") or "/"
|
|
24
|
+
raw = scope.get("raw_path")
|
|
25
|
+
if raw:
|
|
26
|
+
scope["raw_path"] = raw.rstrip(b"/") or b"/"
|
|
27
|
+
await self.app(scope, receive, send)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The owner's profile, mounted at /api/auth/account. Single-user build: no
|
|
2
|
+
sign-up, login, plans or billing; every route works on the local owner
|
|
3
|
+
(marketmyna/api/middlewares/auth.py)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from marketmyna.api.deps import get_config_dep
|
|
15
|
+
from marketmyna.api.middlewares.auth import accounts_db, current_user_from_request
|
|
16
|
+
from marketmyna.core.compliance import mode_for as compliance_mode_for
|
|
17
|
+
from marketmyna.services import accounts
|
|
18
|
+
from marketmyna.services.accounts import AccountError
|
|
19
|
+
from marketmyna.storage import UserRecord
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
|
|
23
|
+
# Everything is on for the owner; the frontend reads these shapes.
|
|
24
|
+
ALL_FEATURES_PLAN = {
|
|
25
|
+
"id": "all",
|
|
26
|
+
"name": "Self-hosted",
|
|
27
|
+
"description": "",
|
|
28
|
+
"ai_calls_month": None,
|
|
29
|
+
"limits": {"watchlist_symbols": None, "price_alerts": None, "scheduled_runs_day": None, "portfolios": None},
|
|
30
|
+
"features": {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def me_dict(user: UserRecord) -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"id": user.id,
|
|
37
|
+
"email": user.email,
|
|
38
|
+
"name": user.display_name or "",
|
|
39
|
+
"role": user.role,
|
|
40
|
+
# "trader" | "investor": the area the menus and pages follow; None shows both.
|
|
41
|
+
"area": user.last_area,
|
|
42
|
+
# IANA zone, e.g. Asia/Kolkata: scheduled report times are in this zone.
|
|
43
|
+
"timezone": user.timezone or "Asia/Kolkata",
|
|
44
|
+
"timezone_auto": user.timezone_auto is not False,
|
|
45
|
+
# Always 'analytics': no buy / sell calls, targets or stops (marketmyna/core/compliance.py).
|
|
46
|
+
"compliance": compliance_mode_for(user.id),
|
|
47
|
+
"plan": ALL_FEATURES_PLAN,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _owner(request: Request, session) -> UserRecord:
|
|
52
|
+
user = current_user_from_request(request, session)
|
|
53
|
+
if user is None:
|
|
54
|
+
raise HTTPException(status_code=500, detail="The local owner account is missing.")
|
|
55
|
+
return user
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/me")
|
|
59
|
+
def me(request: Request, config=Depends(get_config_dep)) -> dict:
|
|
60
|
+
with accounts_db(config).get_session() as session:
|
|
61
|
+
return me_dict(_owner(request, session))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProfileRequest(BaseModel):
|
|
65
|
+
name: Optional[str] = None
|
|
66
|
+
timezone: Optional[str] = None
|
|
67
|
+
# True: the app keeps `timezone` equal to the device's zone (sent on each visit).
|
|
68
|
+
timezone_auto: Optional[bool] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.patch("/profile")
|
|
72
|
+
def update_profile(body: ProfileRequest, request: Request, config=Depends(get_config_dep)) -> dict:
|
|
73
|
+
from marketmyna.services.schedule_time import valid_zone
|
|
74
|
+
|
|
75
|
+
name = " ".join((body.name or "").split()) if body.name is not None else None
|
|
76
|
+
if name is not None and len(name) > 80:
|
|
77
|
+
raise HTTPException(status_code=400, detail="Keep the name under 80 characters.")
|
|
78
|
+
if body.timezone is not None and not valid_zone(body.timezone):
|
|
79
|
+
raise HTTPException(status_code=400, detail="Unknown time zone.")
|
|
80
|
+
with accounts_db(config).get_session() as session:
|
|
81
|
+
user = _owner(request, session)
|
|
82
|
+
if name is not None:
|
|
83
|
+
user.display_name = name
|
|
84
|
+
if body.timezone is not None:
|
|
85
|
+
user.timezone = body.timezone
|
|
86
|
+
if body.timezone_auto is not None:
|
|
87
|
+
user.timezone_auto = body.timezone_auto
|
|
88
|
+
session.flush()
|
|
89
|
+
return me_dict(user)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
AREAS = ("trader", "investor")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AreaRequest(BaseModel):
|
|
96
|
+
area: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.post("/area")
|
|
100
|
+
def choose_area(body: AreaRequest, request: Request, config=Depends(get_config_dep)) -> dict:
|
|
101
|
+
"""Switch between the Trader and Investor areas (kept on the account)."""
|
|
102
|
+
if body.area not in AREAS:
|
|
103
|
+
raise HTTPException(status_code=400, detail="Choose the Trader or the Investor area.")
|
|
104
|
+
with accounts_db(config).get_session() as session:
|
|
105
|
+
user = _owner(request, session)
|
|
106
|
+
user.last_area = body.area
|
|
107
|
+
session.flush()
|
|
108
|
+
return me_dict(user)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/export")
|
|
112
|
+
def export_my_data(request: Request, config=Depends(get_config_dep)) -> Response:
|
|
113
|
+
"""Everything the app stores for you as a JSON download. Credentials are
|
|
114
|
+
left out, see data_export.REDACT."""
|
|
115
|
+
from marketmyna.services.data_export import export_user
|
|
116
|
+
|
|
117
|
+
with accounts_db(config).get_session() as session:
|
|
118
|
+
user = _owner(request, session)
|
|
119
|
+
data = export_user(session, user.id)
|
|
120
|
+
name = f"marketmyna-data-{datetime.now():%Y-%m-%d}.json"
|
|
121
|
+
return Response(
|
|
122
|
+
content=json.dumps(data, ensure_ascii=False, indent=2),
|
|
123
|
+
media_type="application/json",
|
|
124
|
+
headers={"Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---- AI keys: paste a key, the provider and model are worked out from it
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ApiKeyRequest(BaseModel):
|
|
132
|
+
provider: Optional[str] = None # omitted: detected from the key
|
|
133
|
+
key: str # one key, or several separated by new lines, spaces or commas
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _keys(session, user: UserRecord, errors: Optional[list] = None) -> dict:
|
|
137
|
+
out = {"providers": list(accounts.BYOK_PROVIDERS), "keys": accounts.api_keys(session, user)}
|
|
138
|
+
if errors is not None:
|
|
139
|
+
out["errors"] = errors
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/api-keys")
|
|
144
|
+
def list_api_keys(request: Request, config=Depends(get_config_dep)) -> dict:
|
|
145
|
+
with accounts_db(config).get_session() as session:
|
|
146
|
+
return _keys(session, _owner(request, session))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@router.put("/api-keys")
|
|
150
|
+
def put_api_key(body: ApiKeyRequest, request: Request, config=Depends(get_config_dep)) -> dict:
|
|
151
|
+
with accounts_db(config).get_session() as session:
|
|
152
|
+
user = _owner(request, session)
|
|
153
|
+
try:
|
|
154
|
+
errors = accounts.set_api_keys(session, config, user, body.provider, body.key)
|
|
155
|
+
except AccountError as exc:
|
|
156
|
+
raise HTTPException(status_code=exc.status, detail=exc.message)
|
|
157
|
+
return _keys(session, user, errors)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.delete("/api-keys/{key_id}")
|
|
161
|
+
def remove_api_key(key_id: str, request: Request, config=Depends(get_config_dep)) -> dict:
|
|
162
|
+
"""key_id: a key's id, or a provider name (removes all its keys)."""
|
|
163
|
+
with accounts_db(config).get_session() as session:
|
|
164
|
+
user = _owner(request, session)
|
|
165
|
+
accounts.delete_api_key(session, user, key_id)
|
|
166
|
+
return _keys(session, user)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""AI agent analysis endpoints: the technical/risk/decision pipeline
|
|
2
|
+
(marketmyna/agent/orchestrator.py) over a configured LLM backend, exposed as
|
|
3
|
+
one synchronous call and one Server-Sent-Events streaming call."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextvars
|
|
8
|
+
import json
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Literal, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
14
|
+
from fastapi.responses import StreamingResponse
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from marketmyna.agent.context import AgentOpinion
|
|
18
|
+
from marketmyna.agent.dashboard import _public_opinion, build_dashboard
|
|
19
|
+
from marketmyna.agent.orchestrator import AgentOrchestrator
|
|
20
|
+
from marketmyna.api.deps import get_config_dep
|
|
21
|
+
from marketmyna.data.resilience import DataSourceUnavailable
|
|
22
|
+
from marketmyna.llm.channels import llm_configured
|
|
23
|
+
from marketmyna.llm.protocol import GenerationError
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnalyzeRequest(BaseModel):
|
|
29
|
+
symbol: str
|
|
30
|
+
market: str = "us"
|
|
31
|
+
mode: str = "standard"
|
|
32
|
+
# The login area: "trader" gets a short-term trade plan, "investor" a long-term view.
|
|
33
|
+
area: Optional[Literal["trader", "investor"]] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
NO_AI_MESSAGE = (
|
|
37
|
+
"No AI model is set up yet (no llm_model configured). Add an AI key under Settings -> AI keys, "
|
|
38
|
+
"or set LLM_MODEL for a local model. The data cards work without it."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_ai(config) -> None:
|
|
43
|
+
"""The last step (the AI summary) needs a model: refuse before the data steps run for nothing."""
|
|
44
|
+
if not llm_configured(config):
|
|
45
|
+
raise HTTPException(status_code=400, detail=NO_AI_MESSAGE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/agent/status")
|
|
49
|
+
def ai_status(config=Depends(get_config_dep)) -> dict:
|
|
50
|
+
"""Whether Run AI can work: a saved AI key, or a model set in config / env (e.g. a local Ollama)."""
|
|
51
|
+
return {"configured": llm_configured(config)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.post("/agent/analyze")
|
|
55
|
+
def analyze(body: AnalyzeRequest, config=Depends(get_config_dep)) -> dict:
|
|
56
|
+
_require_ai(config)
|
|
57
|
+
orchestrator = AgentOrchestrator(config=config)
|
|
58
|
+
try:
|
|
59
|
+
ctx = orchestrator.run(body.symbol, body.market, mode=body.mode, area=body.area)
|
|
60
|
+
except GenerationError as exc:
|
|
61
|
+
raise HTTPException(status_code=502, detail=f"LLM backend error: {exc}")
|
|
62
|
+
except DataSourceUnavailable as exc:
|
|
63
|
+
raise HTTPException(status_code=503, detail=f"Market data unavailable: {exc}")
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
66
|
+
return build_dashboard(ctx)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stream_agent_events(orchestrator: AgentOrchestrator, body: AnalyzeRequest):
|
|
70
|
+
"""A plain generator (usable standalone, e.g. in tests, or wrapped in
|
|
71
|
+
a StreamingResponse) that relays pipeline progress as it actually
|
|
72
|
+
happens. AgentOrchestrator.run() is a single blocking call, so it runs
|
|
73
|
+
in a background thread; its on_stage callback pushes events onto a
|
|
74
|
+
queue this generator drains and yields as SSE `data:` lines, rather
|
|
75
|
+
than collecting everything and yielding it only after run() returns."""
|
|
76
|
+
events: "queue.Queue[Any]" = queue.Queue()
|
|
77
|
+
done = object()
|
|
78
|
+
# The worker runs in the request's context: the signed-in user's data
|
|
79
|
+
# scope and AI key (a bare thread would start empty).
|
|
80
|
+
context = contextvars.copy_context()
|
|
81
|
+
|
|
82
|
+
def on_stage(event: str, stage: str, payload: object) -> None:
|
|
83
|
+
data: dict = {"event": event, "agent": stage}
|
|
84
|
+
if event == "stage_done" and isinstance(payload, AgentOpinion):
|
|
85
|
+
# No per-agent buy / sell, target or stop, as in the dashboard.
|
|
86
|
+
data["opinion"] = _public_opinion(payload.to_dict())
|
|
87
|
+
elif payload is not None:
|
|
88
|
+
data["message"] = str(payload)
|
|
89
|
+
events.put(data)
|
|
90
|
+
|
|
91
|
+
def worker() -> None:
|
|
92
|
+
try:
|
|
93
|
+
ctx = orchestrator.run(body.symbol, body.market, mode=body.mode, on_stage=on_stage, area=body.area)
|
|
94
|
+
except Exception as exc: # noqa: BLE001 - the one place a critical failure surfaces to the client
|
|
95
|
+
events.put({"event": "error", "message": str(exc)})
|
|
96
|
+
else:
|
|
97
|
+
events.put({"event": "done", "dashboard": build_dashboard(ctx)})
|
|
98
|
+
finally:
|
|
99
|
+
events.put(done)
|
|
100
|
+
|
|
101
|
+
threading.Thread(target=context.run, args=(worker,), daemon=True).start()
|
|
102
|
+
|
|
103
|
+
while True:
|
|
104
|
+
item = events.get()
|
|
105
|
+
if item is done:
|
|
106
|
+
return
|
|
107
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/agent/analyze/stream")
|
|
111
|
+
def analyze_stream(body: AnalyzeRequest, config=Depends(get_config_dep)) -> StreamingResponse:
|
|
112
|
+
_require_ai(config)
|
|
113
|
+
orchestrator = AgentOrchestrator(config=config)
|
|
114
|
+
return StreamingResponse(stream_agent_events(orchestrator, body), media_type="text/event-stream")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Platform-agnostic bot-command endpoint: exercises the same
|
|
2
|
+
CommandDispatcher a real chat platform listener uses, without needing
|
|
3
|
+
any chat platform at all - the web app's Bot console, or curl.
|
|
4
|
+
|
|
5
|
+
Open to every signed-in user: the auth middleware scopes each request to
|
|
6
|
+
its user, so /portfolio, /alerts and the rest see only their own rows, and
|
|
7
|
+
the AI commands go through the same plan quota as the Analyze page - the
|
|
8
|
+
same as a linked Telegram chat."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from marketmyna.api.deps import get_bot_dispatcher
|
|
16
|
+
from marketmyna.bot.models import IncomingMessage
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BotCommandRequest(BaseModel):
|
|
22
|
+
text: str
|
|
23
|
+
platform: str = "http"
|
|
24
|
+
chat_id: str = "http"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/bot/command")
|
|
28
|
+
def run_bot_command(body: BotCommandRequest, dispatcher=Depends(get_bot_dispatcher)) -> dict:
|
|
29
|
+
message = IncomingMessage(platform=body.platform, chat_id=body.chat_id, text=body.text)
|
|
30
|
+
result = dispatcher.dispatch(message)
|
|
31
|
+
return {"text": result.text, "success": result.success}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/bot/commands")
|
|
35
|
+
def list_bot_commands(dispatcher=Depends(get_bot_dispatcher)) -> list[dict]:
|
|
36
|
+
"""Every registered command with usage, description, group and an example
|
|
37
|
+
(the console's reference panel and "/" menu)."""
|
|
38
|
+
return [{**c.describe(), "group": group} for group, commands in dispatcher.groups().items() for c in commands]
|