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,323 @@
|
|
|
1
|
+
"""Stock research from chat: the same services the Analyze page shows
|
|
2
|
+
(quote, overview, valuation, financials, estimates, dividends, peers,
|
|
3
|
+
Shariah screen, compare), as short text replies.
|
|
4
|
+
|
|
5
|
+
Every command takes SYMBOL [market]; "us:AAPL" works too. Without a
|
|
6
|
+
market the symbol is looked up in the listings (see common.guess_market).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ...utils.deadline import call_with_deadline
|
|
12
|
+
from ..models import CommandResult, IncomingMessage
|
|
13
|
+
from .base import Command
|
|
14
|
+
from .common import big, num, parse_symbol, pct
|
|
15
|
+
|
|
16
|
+
GROUP = "Stock research"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _SymbolCommand(Command):
|
|
20
|
+
group = GROUP
|
|
21
|
+
|
|
22
|
+
def __init__(self, config):
|
|
23
|
+
self._config = config
|
|
24
|
+
|
|
25
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
26
|
+
symbol, market, rest = parse_symbol(args, self._config)
|
|
27
|
+
if not symbol:
|
|
28
|
+
return CommandResult(text=f"Usage: {self.usage}\nExample: {self.example}", success=False)
|
|
29
|
+
try:
|
|
30
|
+
return CommandResult(text=self.run(symbol, market, rest))
|
|
31
|
+
except TimeoutError:
|
|
32
|
+
return CommandResult(text=f"{symbol}: the data source took too long. Try again.", success=False)
|
|
33
|
+
except Exception as exc: # noqa: BLE001 - one bad source must not crash the chat
|
|
34
|
+
return CommandResult(text=f"{symbol} ({market}): {exc}", success=False)
|
|
35
|
+
|
|
36
|
+
def run(self, symbol: str, market: str, rest: list[str]) -> str:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QuoteCommand(_SymbolCommand):
|
|
41
|
+
name = "quote"
|
|
42
|
+
aliases = ["q", "price"]
|
|
43
|
+
usage = "/quote SYMBOL [market]"
|
|
44
|
+
description = "Live price, day change, day range, volume and market cap."
|
|
45
|
+
example = "/quote RELIANCE india"
|
|
46
|
+
|
|
47
|
+
def run(self, symbol, market, rest):
|
|
48
|
+
from ...core.constants import Markets
|
|
49
|
+
from ...data.china import ChinaAFetcher, HongKongFetcher
|
|
50
|
+
from ...data.crypto import CryptoDataFetcher
|
|
51
|
+
from ...data.india import IndiaDataFetcher
|
|
52
|
+
from ...data.stocks import StockDataFetcher
|
|
53
|
+
|
|
54
|
+
fetcher = {"india": IndiaDataFetcher, "crypto": CryptoDataFetcher, "china_a": ChinaAFetcher,
|
|
55
|
+
"hong_kong": HongKongFetcher}.get(Markets(market).value, StockDataFetcher)()
|
|
56
|
+
q = call_with_deadline(fetcher.get_quote, 20, symbol) or {}
|
|
57
|
+
# Exchange quotes can come back thin (e.g. NSE off-hours); fill gaps from the listings row.
|
|
58
|
+
try:
|
|
59
|
+
from ...data.listings import get_store
|
|
60
|
+
|
|
61
|
+
found = get_store().lookup([(market, kind, symbol) for kind in ("stock", "etf")])
|
|
62
|
+
row = next(iter(found.values()), {})
|
|
63
|
+
except Exception:
|
|
64
|
+
row = {}
|
|
65
|
+
for key, alt in (("price", "price"), ("open", "open"), ("day_high", "high"), ("day_low", "low"),
|
|
66
|
+
("volume", "volume"), ("market_cap", "market_cap"), ("change_percent", "change_percent")):
|
|
67
|
+
if not q.get(key) and row.get(alt):
|
|
68
|
+
q[key] = row[alt]
|
|
69
|
+
if q.get("price") is None:
|
|
70
|
+
return f"No quote for {symbol} ({market})."
|
|
71
|
+
def pair(*items):
|
|
72
|
+
return " · ".join(f"{label} {fmt(q[key])}" for label, key, fmt in items if q.get(key))
|
|
73
|
+
|
|
74
|
+
lines = [f"{symbol} ({market}) {num(q['price'])} {num(q.get('change'))} ({pct(q.get('change_percent'), 2)})",
|
|
75
|
+
pair(("Open", "open", num), ("Prev close", "previous_close", num)),
|
|
76
|
+
f"Day range {num(q['day_low'])} - {num(q['day_high'])}" if q.get("day_low") and q.get("day_high") else "",
|
|
77
|
+
pair(("Volume", "volume", big), ("Market cap", "market_cap", big))]
|
|
78
|
+
return "\n".join(line for line in lines if line)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OverviewCommand(_SymbolCommand):
|
|
82
|
+
name = "overview"
|
|
83
|
+
aliases = ["ov", "tech"]
|
|
84
|
+
usage = "/overview SYMBOL [market]"
|
|
85
|
+
description = "Technicals, trend, returns, risk, fundamentals, analyst consensus and upcoming events."
|
|
86
|
+
example = "/overview AAPL us"
|
|
87
|
+
|
|
88
|
+
def run(self, symbol, market, rest):
|
|
89
|
+
from ...services.overview import build_overview
|
|
90
|
+
|
|
91
|
+
d = call_with_deadline(build_overview, 60, symbol, market)
|
|
92
|
+
t, r, f, a, e = (d.get(k) or {} for k in ("technical", "risk", "fundamentals", "analysts", "events"))
|
|
93
|
+
lines = [f"{symbol} ({market}) overview"]
|
|
94
|
+
if t:
|
|
95
|
+
counts = t.get("counts") # absent on a public deploy (no per-stock verdicts)
|
|
96
|
+
lines.append(f"Trend: {t.get('trend', 'n/a')}" + (
|
|
97
|
+
f" · indicators {counts.get('buy', 0)} up / {counts.get('sell', 0)} down / "
|
|
98
|
+
f"{counts.get('neutral', 0)} neutral" if counts else ""))
|
|
99
|
+
ret = t.get("returns") or {}
|
|
100
|
+
lines.append("Returns: " + " · ".join(f"{k} {pct(v)}" for k, v in list(ret.items())[:6]))
|
|
101
|
+
lines.append(f"52w range {num(t.get('low_52w'))} - {num(t.get('high_52w'))} "
|
|
102
|
+
f"({pct(t.get('from_high_pct'))} from high)")
|
|
103
|
+
rsi = next((i for i in t.get("indicators") or [] if i.get("key") == "rsi"), None)
|
|
104
|
+
if rsi:
|
|
105
|
+
lines.append(f"RSI(14) {num(rsi.get('value'), 1)}")
|
|
106
|
+
if r:
|
|
107
|
+
lines.append(f"Risk: {r.get('level', 'n/a')} · volatility {pct(r.get('volatility_pct'), 1, False)} · "
|
|
108
|
+
f"max drawdown {pct(r.get('max_drawdown_pct'))} · beta {num(r.get('beta'))}")
|
|
109
|
+
if f:
|
|
110
|
+
lines.append(f"Fundamentals: score {f.get('score', 'n/a')}/100 · health {f.get('health_rating', 'n/a')}")
|
|
111
|
+
if a.get("count"):
|
|
112
|
+
# Third-party consensus as a direction (build_overview), without the price targets.
|
|
113
|
+
lines.append(f"Broker consensus ({a['count']} analysts): {a.get('recommendation') or 'n/a'}")
|
|
114
|
+
ev = [f"earnings {e['earnings_date']}" if e.get("earnings_date") else "",
|
|
115
|
+
f"ex-dividend {e['ex_dividend_date']}" if e.get("ex_dividend_date") else ""]
|
|
116
|
+
if any(ev):
|
|
117
|
+
lines.append("Events: " + " · ".join(x for x in ev if x))
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ValuationCommand(_SymbolCommand):
|
|
122
|
+
name = "valuation"
|
|
123
|
+
aliases = ["val"]
|
|
124
|
+
usage = "/valuation SYMBOL [market]"
|
|
125
|
+
description = "Valuation meter: P/E, P/B and other multiples against the sector median, plus the growth-at-a-price check."
|
|
126
|
+
example = "/valuation INFY india"
|
|
127
|
+
|
|
128
|
+
def run(self, symbol, market, rest):
|
|
129
|
+
from ...services.valuation import meter
|
|
130
|
+
|
|
131
|
+
d = call_with_deadline(meter, 90, symbol, market)
|
|
132
|
+
if d.get("status") != "ok":
|
|
133
|
+
return f"{symbol}: {d.get('reason') or 'no valuation data'}"
|
|
134
|
+
zone = {"below": "below its sector", "above": "above its sector", "in_line": "in line with its sector"}
|
|
135
|
+
lines = [f"{symbol} ({market}) valuation vs {d.get('sector')} ({d.get('peers')} peers): "
|
|
136
|
+
f"priced {zone.get(d.get('zone'), d.get('zone'))}"]
|
|
137
|
+
for m in d.get("multiples") or []:
|
|
138
|
+
if m.get("value") is not None:
|
|
139
|
+
lines.append(f"• {m['label']} {num(m['value'])} vs sector median {num(m.get('sector_median'))}")
|
|
140
|
+
g = d.get("garp") or {}
|
|
141
|
+
if g.get("total"):
|
|
142
|
+
lines.append(f"Growth at a reasonable price: {g['passed']}/{g['total']} checks pass (PEG {num(g.get('peg'))})")
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class FinancialsCommand(_SymbolCommand):
|
|
147
|
+
name = "financials"
|
|
148
|
+
aliases = ["fin"]
|
|
149
|
+
usage = "/financials SYMBOL [market] [annual|quarterly]"
|
|
150
|
+
description = "Income statement, balance sheet and cash flow health with the key findings."
|
|
151
|
+
example = "/financials TCS india"
|
|
152
|
+
|
|
153
|
+
def run(self, symbol, market, rest):
|
|
154
|
+
from ...services.financials import build_financials
|
|
155
|
+
|
|
156
|
+
period = "quarterly" if rest and rest[0].lower().startswith("q") else "annual"
|
|
157
|
+
d = call_with_deadline(build_financials, 60, symbol, market, period)
|
|
158
|
+
o = d.get("overall") or {}
|
|
159
|
+
lines = [f"{d.get('name') or symbol} ({market}) {period}: {o.get('label', 'n/a')} ({o.get('score', 'n/a')}/100)"]
|
|
160
|
+
for key, title in (("income", "Income statement"), ("balance", "Balance sheet"), ("cashflow", "Cash flow")):
|
|
161
|
+
part = d.get(key) or {}
|
|
162
|
+
v = part.get("verdict") or {}
|
|
163
|
+
if not v:
|
|
164
|
+
continue
|
|
165
|
+
lines.append(f"{title}: {v.get('label')} ({v.get('score')}) as of {part.get('as_of')}")
|
|
166
|
+
for f in (part.get("findings") or [])[:3]:
|
|
167
|
+
lines.append(f" {'+' if f.get('tone') == 'good' else '-' if f.get('tone') == 'bad' else '·'} {f['text']}")
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class EstimatesCommand(_SymbolCommand):
|
|
172
|
+
name = "estimates"
|
|
173
|
+
aliases = ["est"]
|
|
174
|
+
usage = "/estimates SYMBOL [market]"
|
|
175
|
+
description = "Analyst EPS and revenue consensus, revisions and recent beats or misses."
|
|
176
|
+
example = "/estimates MSFT us"
|
|
177
|
+
|
|
178
|
+
def run(self, symbol, market, rest):
|
|
179
|
+
from ...services.analysis_run import yahoo_symbol
|
|
180
|
+
from ...services.estimates import build
|
|
181
|
+
|
|
182
|
+
if market == "crypto":
|
|
183
|
+
return "No analyst estimates for crypto."
|
|
184
|
+
d = call_with_deadline(build, 45, yahoo_symbol(symbol, market))
|
|
185
|
+
if not d.get("available"):
|
|
186
|
+
return f"No analyst estimates for {symbol}."
|
|
187
|
+
cur = d.get("currency") or ""
|
|
188
|
+
lines = [f"{symbol} analyst estimates ({cur})"]
|
|
189
|
+
for p in d.get("periods") or []:
|
|
190
|
+
eps, rev, r = p.get("eps") or {}, p.get("revenue") or {}, p.get("revisions") or {}
|
|
191
|
+
growth = eps.get("growth")
|
|
192
|
+
lines.append(f"• {p['label']}: EPS {num(eps.get('avg'))}"
|
|
193
|
+
+ (f" ({pct(growth * 100 if abs(growth) < 5 else growth)} y/y)" if growth is not None else "")
|
|
194
|
+
+ (f", revenue {big(rev.get('avg'))}" if rev.get("avg") else "")
|
|
195
|
+
+ (f", 30d revisions {int(r.get('up_30d') or 0)} up / {int(r.get('down_30d') or 0)} down" if r else ""))
|
|
196
|
+
m, b = d.get("momentum") or {}, d.get("beats") or {}
|
|
197
|
+
if m.get("label"):
|
|
198
|
+
lines.append(f"Estimate trend (90d): {m['label']}")
|
|
199
|
+
if b.get("total"):
|
|
200
|
+
lines.append(f"Beat estimates in {b['beat']} of the last {b['total']} quarters")
|
|
201
|
+
return "\n".join(lines)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class DividendsCommand(_SymbolCommand):
|
|
205
|
+
name = "dividends"
|
|
206
|
+
aliases = ["div"]
|
|
207
|
+
usage = "/dividends SYMBOL [market]"
|
|
208
|
+
description = "Dividend yield, payout ratio, growth, years without a cut and the last payment."
|
|
209
|
+
example = "/dividends ITC india"
|
|
210
|
+
|
|
211
|
+
def run(self, symbol, market, rest):
|
|
212
|
+
from ...services.analysis_run import yahoo_symbol
|
|
213
|
+
from ...services.dividends import build
|
|
214
|
+
|
|
215
|
+
if market == "crypto":
|
|
216
|
+
return "Crypto doesn't pay dividends."
|
|
217
|
+
d = call_with_deadline(build, 45, yahoo_symbol(symbol, market))
|
|
218
|
+
if not d.get("pays"):
|
|
219
|
+
return f"{symbol} has no dividend history."
|
|
220
|
+
last = d.get("last") or {}
|
|
221
|
+
return "\n".join([
|
|
222
|
+
f"{symbol} dividends ({d.get('currency')}) · {d.get('frequency') or 'irregular'}",
|
|
223
|
+
f"Trailing yield {pct(d.get('ttm_yield'), 2, False)} ({num(d.get('ttm'))} in 12 months) · "
|
|
224
|
+
f"forward {pct(d.get('forward_yield'), 2, False)}",
|
|
225
|
+
f"Payout ratio {pct(d.get('payout_ratio'), 0, False)} · 5y growth {pct(d.get('growth_5y'))} · "
|
|
226
|
+
f"{d.get('years_without_cut', 'n/a')} year{'' if d.get('years_without_cut') == 1 else 's'} without a cut",
|
|
227
|
+
f"Last paid {num(last.get('amount'))} on {last.get('date', 'n/a')}"
|
|
228
|
+
+ (f" · next ex-date {d['next_ex_date']}" if d.get("next_ex_date") else ""),
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class PeersCommand(_SymbolCommand):
|
|
233
|
+
name = "peers"
|
|
234
|
+
aliases = []
|
|
235
|
+
usage = "/peers SYMBOL [market]"
|
|
236
|
+
description = "Closest companies in the same sector by size, with P/E and day change."
|
|
237
|
+
example = "/peers HDFCBANK india"
|
|
238
|
+
|
|
239
|
+
def run(self, symbol, market, rest):
|
|
240
|
+
from ...services.compare import peers
|
|
241
|
+
|
|
242
|
+
d = call_with_deadline(peers, 60, symbol, market, 6)
|
|
243
|
+
rows = d.get("peers") or []
|
|
244
|
+
if not rows:
|
|
245
|
+
return f"No peers found for {symbol}."
|
|
246
|
+
lines = [f"{symbol} peers · {d.get('basis')}"]
|
|
247
|
+
for p in rows:
|
|
248
|
+
lines.append(f"• {p['symbol']} cap {big(p.get('market_cap'))} · P/E {num(p.get('pe'), 1)} · "
|
|
249
|
+
f"day {pct(p.get('change_percent'))}")
|
|
250
|
+
lines.append(f"Compare: /compare {symbol}," + ",".join(p["symbol"] for p in rows[:3]) + f" {market}")
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ShariahCommand(_SymbolCommand):
|
|
255
|
+
name = "ethical"
|
|
256
|
+
aliases = ["shariah", "halal"] # the old names keep working
|
|
257
|
+
usage = "/ethical SYMBOL [market]"
|
|
258
|
+
description = "Ethical screen: business activity and the debt, cash and interest-income ratios."
|
|
259
|
+
example = "/ethical AAPL us"
|
|
260
|
+
|
|
261
|
+
def run(self, symbol, market, rest):
|
|
262
|
+
from ...services.shariah import build_shariah_profile
|
|
263
|
+
|
|
264
|
+
d = call_with_deadline(build_shariah_profile, 60, symbol, market)
|
|
265
|
+
lines = [f"{d.get('name') or symbol}: {d.get('verdict_label') or d.get('verdict') or 'no verdict'}"]
|
|
266
|
+
b = d.get("business") or {}
|
|
267
|
+
if b.get("reason"):
|
|
268
|
+
lines.append(f"Business: {b['reason']}")
|
|
269
|
+
for c in d.get("checks") or []:
|
|
270
|
+
v = "n/a" if c.get("value") is None else f"{c['value']:.1f}%"
|
|
271
|
+
lines.append(f"• {c['label']}: {v} (limit {c.get('limit')}%) {c.get('status', '')}")
|
|
272
|
+
lines.append("A screening aid, not a fatwa.")
|
|
273
|
+
return "\n".join(lines)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class CompareCommand(Command):
|
|
277
|
+
name = "compare"
|
|
278
|
+
aliases = ["vs"]
|
|
279
|
+
usage = "/compare SYM1,SYM2[,SYM3] [market] [6mo|1y|3y|5y]"
|
|
280
|
+
description = "2-5 stocks side by side: returns, volatility, drawdown, beta and correlation."
|
|
281
|
+
example = "/compare TCS,INFY,WIPRO india 1y"
|
|
282
|
+
group = GROUP
|
|
283
|
+
|
|
284
|
+
def __init__(self, config):
|
|
285
|
+
self._config = config
|
|
286
|
+
|
|
287
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
288
|
+
from ...services.compare import compare
|
|
289
|
+
from .common import guess_market, market_word
|
|
290
|
+
|
|
291
|
+
parts = args.split()
|
|
292
|
+
if not parts:
|
|
293
|
+
return CommandResult(text=f"Usage: {self.usage}\nExample: {self.example}", success=False)
|
|
294
|
+
market = next((market_word(p) for p in parts[1:] if market_word(p)), None)
|
|
295
|
+
period = next((p.lower() for p in parts[1:] if p.lower() in ("6mo", "1y", "3y", "5y")), "1y")
|
|
296
|
+
items = []
|
|
297
|
+
for tok in parts[0].split(","):
|
|
298
|
+
if not tok:
|
|
299
|
+
continue
|
|
300
|
+
m, _, s = tok.partition(":") if ":" in tok else ("", "", tok)
|
|
301
|
+
sym = s.upper()
|
|
302
|
+
items.append((sym, market_word(m) or market or guess_market(sym, self._config)))
|
|
303
|
+
if not 2 <= len(items) <= 5:
|
|
304
|
+
return CommandResult(text="Give 2 to 5 symbols, comma separated.", success=False)
|
|
305
|
+
try:
|
|
306
|
+
d = call_with_deadline(compare, 60, items, period)
|
|
307
|
+
except (ValueError, TimeoutError) as exc:
|
|
308
|
+
return CommandResult(text=str(exc), success=False)
|
|
309
|
+
syms = [i["symbol"] for i in d.get("items") or []]
|
|
310
|
+
lines = [f"Compare {', '.join(syms)} ({period})"]
|
|
311
|
+
for row in d.get("returns") or []:
|
|
312
|
+
lines.append(f"{row['window']}: " + " · ".join(f"{s} {pct(row['values'].get(s))}" for s in syms))
|
|
313
|
+
risk = d.get("risk") or {}
|
|
314
|
+
for s in syms:
|
|
315
|
+
r = risk.get(s) or {}
|
|
316
|
+
lines.append(f"{s}: volatility {pct(r.get('volatility'), 1, False)} · max drawdown {pct(r.get('max_drawdown'))} · "
|
|
317
|
+
f"beta {num(r.get('beta'))}")
|
|
318
|
+
corr = d.get("correlation") or {}
|
|
319
|
+
cs, mx = corr.get("symbols") or [], corr.get("matrix") or []
|
|
320
|
+
pairs = [f"{cs[i]}/{cs[j]} {mx[i][j]:.2f}" for i in range(len(cs)) for j in range(i + 1, len(cs)) if mx[i][j] is not None]
|
|
321
|
+
if pairs:
|
|
322
|
+
lines.append("Correlation: " + " · ".join(pairs[:6]))
|
|
323
|
+
return CommandResult(text="\n".join(lines))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""/status - reports the persisted schedule config plus whether the
|
|
2
|
+
scheduler thread is currently running.
|
|
3
|
+
|
|
4
|
+
Unlike an HTTP endpoint, a bot command has no request.app.state to pull
|
|
5
|
+
the live RuntimeSchedulerService from - `scheduler_running` is an
|
|
6
|
+
optional callable injected by whoever wires the bot into the app (see
|
|
7
|
+
marketmyna/api/app.py's lifespan), so this command never reaches into global
|
|
8
|
+
state itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
from ...services.schedule_config import ScheduleConfigStore
|
|
16
|
+
from ..models import CommandResult, IncomingMessage
|
|
17
|
+
from .base import Command
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StatusCommand(Command):
|
|
21
|
+
name = "status"
|
|
22
|
+
aliases = ["s"]
|
|
23
|
+
usage = "/status"
|
|
24
|
+
description = "Scheduled-report settings and whether the scheduler is running."
|
|
25
|
+
group = "System"
|
|
26
|
+
example = "/status"
|
|
27
|
+
help_text = "/status - show the scheduled-analysis config and whether it's running"
|
|
28
|
+
|
|
29
|
+
def __init__(self, config, scheduler_running: Optional[Callable[[], bool]] = None):
|
|
30
|
+
self._config = config
|
|
31
|
+
self._scheduler_running = scheduler_running
|
|
32
|
+
|
|
33
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
34
|
+
cfg = ScheduleConfigStore(config=self._config).load()
|
|
35
|
+
|
|
36
|
+
running = self._scheduler_running() if self._scheduler_running else None
|
|
37
|
+
lines = [f"Scheduler thread: {'running' if running else 'not running' if running is not None else 'unknown'}"]
|
|
38
|
+
lines.append(f"Scheduled analysis: {'enabled' if cfg.enabled else 'disabled'}")
|
|
39
|
+
if cfg.symbols:
|
|
40
|
+
lines.append(f"Symbols: {', '.join(cfg.symbols)} ({cfg.market}, {cfg.mode})")
|
|
41
|
+
if cfg.interval_minutes:
|
|
42
|
+
lines.append(f"Every {cfg.interval_minutes} minutes")
|
|
43
|
+
elif cfg.time_of_day:
|
|
44
|
+
lines.append(f"Daily at {cfg.time_of_day}")
|
|
45
|
+
if cfg.last_run_at:
|
|
46
|
+
lines.append(f"Last run: {cfg.last_run_at.isoformat()} ({cfg.last_run_status})")
|
|
47
|
+
|
|
48
|
+
return CommandResult(text="\n".join(lines))
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Trade section from chat: /alert, /alerts, /risk, /scan.
|
|
2
|
+
|
|
3
|
+
Symbols take an optional market prefix like the scheduler's STOCK_LIST
|
|
4
|
+
(us:AAPL, crypto:BTC-USD); without one, the Risk tab's market is used."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ...storage import DatabaseManager, PriceAlertRecord, SavedScanRecord, get_db_url
|
|
9
|
+
from ..models import CommandResult, IncomingMessage
|
|
10
|
+
from .base import Command
|
|
11
|
+
|
|
12
|
+
MARKETS = {"us", "india", "china_a", "hong_kong", "crypto", "global"}
|
|
13
|
+
KIND_WORDS = {
|
|
14
|
+
"above": "price_above", ">": "price_above", ">=": "price_above",
|
|
15
|
+
"below": "price_below", "<": "price_below", "<=": "price_below",
|
|
16
|
+
"up": "pct_up", "down": "pct_down",
|
|
17
|
+
"rsi>": "rsi_above", "rsi<": "rsi_below",
|
|
18
|
+
"volume": "volume_spike", "vol": "volume_spike",
|
|
19
|
+
"breakout": "high_breakout", "breakdown": "low_breakdown",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _db(config):
|
|
24
|
+
return DatabaseManager.instance(get_db_url(config=config))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_market(config) -> str:
|
|
28
|
+
from ...trading.risk import load_settings
|
|
29
|
+
|
|
30
|
+
return load_settings(config).get("market") or "india"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _symbol(token: str, config) -> tuple[str, str]:
|
|
34
|
+
if ":" in token:
|
|
35
|
+
market, sym = token.split(":", 1)
|
|
36
|
+
if market.lower() in MARKETS:
|
|
37
|
+
return sym.upper(), market.lower()
|
|
38
|
+
return token.upper(), _default_market(config)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AlertCommand(Command):
|
|
42
|
+
name = "alert"
|
|
43
|
+
aliases = ["al"]
|
|
44
|
+
usage = "/alert SYMBOL above|below PRICE [note]"
|
|
45
|
+
description = "Create a price alert (also up|down %, rsi>|rsi< N, volume X, breakout|breakdown DAYS)."
|
|
46
|
+
group = "Trading"
|
|
47
|
+
example = "/alert india:RELIANCE below 1210"
|
|
48
|
+
help_text = (
|
|
49
|
+
"/alert SYMBOL above|below PRICE [note] - price alert; also up|down PCT, rsi>|rsi< N, "
|
|
50
|
+
"volume X, breakout|breakdown DAYS. Prefix the market: us:AAPL, crypto:BTC-USD"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __init__(self, config):
|
|
54
|
+
self._config = config
|
|
55
|
+
|
|
56
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
57
|
+
from ...trading.alerts import describe
|
|
58
|
+
|
|
59
|
+
parts = args.split()
|
|
60
|
+
if len(parts) < 3:
|
|
61
|
+
return CommandResult(text=f"Usage: {self.help_text}", success=False)
|
|
62
|
+
symbol, market = _symbol(parts[0], self._config)
|
|
63
|
+
kind = KIND_WORDS.get(parts[1].lower())
|
|
64
|
+
if kind is None:
|
|
65
|
+
return CommandResult(text=f"Unknown condition '{parts[1]}'. Use one of: {', '.join(sorted(KIND_WORDS))}", success=False)
|
|
66
|
+
try:
|
|
67
|
+
value = float(parts[2].rstrip("%x"))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return CommandResult(text=f"'{parts[2]}' is not a number", success=False)
|
|
70
|
+
note = " ".join(parts[3:])
|
|
71
|
+
with _db(self._config).get_session() as s:
|
|
72
|
+
rec = PriceAlertRecord(symbol=symbol, market=market, kind=kind, value=value, note=note, status="active", armed=True)
|
|
73
|
+
s.add(rec)
|
|
74
|
+
s.flush()
|
|
75
|
+
alert_id = rec.id
|
|
76
|
+
return CommandResult(text=f"🔔 Alert #{alert_id} set: {symbol} ({market}) {describe(kind, value)}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AlertsCommand(Command):
|
|
80
|
+
name = "alerts"
|
|
81
|
+
aliases = []
|
|
82
|
+
usage = "/alerts [delete|pause|resume ID]"
|
|
83
|
+
description = "List your active alerts, or delete / pause / resume one."
|
|
84
|
+
group = "Trading"
|
|
85
|
+
example = "/alerts"
|
|
86
|
+
help_text = "/alerts - list active alerts · /alerts delete ID · /alerts pause ID · /alerts resume ID"
|
|
87
|
+
|
|
88
|
+
def __init__(self, config):
|
|
89
|
+
self._config = config
|
|
90
|
+
|
|
91
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
92
|
+
from ...trading.alerts import describe
|
|
93
|
+
|
|
94
|
+
parts = args.split()
|
|
95
|
+
if len(parts) == 2 and parts[0] in ("delete", "pause", "resume") and parts[1].lstrip("#").isdigit():
|
|
96
|
+
alert_id = int(parts[1].lstrip("#"))
|
|
97
|
+
with _db(self._config).get_session() as s:
|
|
98
|
+
rec = s.get(PriceAlertRecord, alert_id)
|
|
99
|
+
if rec is None:
|
|
100
|
+
return CommandResult(text=f"No alert #{alert_id}", success=False)
|
|
101
|
+
if parts[0] == "delete":
|
|
102
|
+
s.delete(rec)
|
|
103
|
+
else:
|
|
104
|
+
rec.status = "paused" if parts[0] == "pause" else "active"
|
|
105
|
+
rec.armed = True
|
|
106
|
+
return CommandResult(text=f"Alert #{alert_id} {parts[0]}d")
|
|
107
|
+
with _db(self._config).get_session() as s:
|
|
108
|
+
recs = s.query(PriceAlertRecord).filter(PriceAlertRecord.status.in_(["active", "paused"])).order_by(PriceAlertRecord.id).all()
|
|
109
|
+
if not recs:
|
|
110
|
+
return CommandResult(text="No active alerts. Create one with /alert RELIANCE below 1210")
|
|
111
|
+
lines = [f"#{r.id} {r.symbol} ({r.market}): {describe(r.kind, r.value)}"
|
|
112
|
+
+ (" [paused]" if r.status == "paused" else "") + (f" · {r.note}" if r.note else "") for r in recs]
|
|
113
|
+
return CommandResult(text="\n".join(lines))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RiskCommand(Command):
|
|
117
|
+
name = "risk"
|
|
118
|
+
aliases = []
|
|
119
|
+
usage = "/risk"
|
|
120
|
+
description = "Today's P&L against your daily loss limit, open risk and trade count."
|
|
121
|
+
group = "Trading"
|
|
122
|
+
example = "/risk"
|
|
123
|
+
help_text = "/risk - today's P&L against your daily loss limit, open risk and trade count"
|
|
124
|
+
|
|
125
|
+
def __init__(self, config):
|
|
126
|
+
self._config = config
|
|
127
|
+
|
|
128
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
129
|
+
from ...trading.risk import status
|
|
130
|
+
|
|
131
|
+
r = status(self._config)
|
|
132
|
+
icon = {"ok": "✅", "warn": "⚠️", "stop": "🛑"}[r["status"]]
|
|
133
|
+
lines = [
|
|
134
|
+
f"{icon} Risk: {r['status'].upper()} ({r['settings']['market']})",
|
|
135
|
+
f"Net P&L today: {r['day_pnl']:,.2f} (realised {r['realized']:,.2f}, open {r['unrealized']:,.2f})",
|
|
136
|
+
f"Loss limit: {r['loss_limit']:,.2f}" + (f" · {r['limit_used_pct']}% used" if r["limit_used_pct"] else ""),
|
|
137
|
+
f"Open risk to stops: {r['open_risk']:,.2f} ({r['open_risk_pct']}% of capital)",
|
|
138
|
+
f"Trades today: {r['trades_today']} / {r['settings']['max_trades_per_day']}",
|
|
139
|
+
]
|
|
140
|
+
lines += [f"• {x}" for x in r["reasons"]]
|
|
141
|
+
return CommandResult(text="\n".join(lines))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ScanCommand(Command):
|
|
145
|
+
name = "scan"
|
|
146
|
+
aliases = []
|
|
147
|
+
usage = "/scan [NAME|ID]"
|
|
148
|
+
description = "List saved scans, or run one now."
|
|
149
|
+
group = "Trading"
|
|
150
|
+
example = "/scan"
|
|
151
|
+
help_text = "/scan - list saved scans · /scan NAME or ID - run one now"
|
|
152
|
+
|
|
153
|
+
def __init__(self, config):
|
|
154
|
+
self._config = config
|
|
155
|
+
|
|
156
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
157
|
+
from ...trading.scanner import run_saved_scans
|
|
158
|
+
|
|
159
|
+
with _db(self._config).get_session() as s:
|
|
160
|
+
saved = [(r.id, r.name) for r in s.query(SavedScanRecord).order_by(SavedScanRecord.id)]
|
|
161
|
+
if not saved:
|
|
162
|
+
return CommandResult(text="No saved scans. Save one in Trade → Scanner.")
|
|
163
|
+
key = args.strip().lstrip("#").lower()
|
|
164
|
+
if not key:
|
|
165
|
+
return CommandResult(text="\n".join(f"#{i} {n}" for i, n in saved) + "\nRun one with /scan NAME")
|
|
166
|
+
match = next((i for i, n in saved if str(i) == key or n.lower() == key), None) or next(
|
|
167
|
+
(i for i, n in saved if key in n.lower()), None)
|
|
168
|
+
if match is None:
|
|
169
|
+
return CommandResult(text=f"No saved scan matches '{args.strip()}'", success=False)
|
|
170
|
+
out = run_saved_scans(self._config, force_id=match)[0]
|
|
171
|
+
if not out["rows"]:
|
|
172
|
+
return CommandResult(text=f"🔎 {out['name']}: no matches in {out['scanned']} stocks")
|
|
173
|
+
lines = [f"🔎 {out['name']}: {len(out['rows'])} matches in {out['scanned']} stocks"]
|
|
174
|
+
for r in out["rows"][:20]:
|
|
175
|
+
lines.append(f"• {r['symbol']} {r['price']:,.2f} ({r['change_percent']:+.1f}%): {', '.join(r['reasons'][:2])}")
|
|
176
|
+
return CommandResult(text="\n".join(lines))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Routes an IncomingMessage to a registered Command.
|
|
2
|
+
|
|
3
|
+
Parsing is forgiving: "/analyze AAPL" is the normal form, but a bare
|
|
4
|
+
"analyze AAPL" (first word matching a registered command name/alias,
|
|
5
|
+
no leading slash) also routes correctly. Anything else with no
|
|
6
|
+
recognized command word falls through to the "ask" command if one is
|
|
7
|
+
registered (so a plain question like "should I buy AAPL?" gets a
|
|
8
|
+
useful answer instead of an "unknown command" reply) - the same
|
|
9
|
+
graceful-fallback shape as daily_stock_analysis's own dispatcher, kept
|
|
10
|
+
much smaller here (no rate limiting, no admin users, no NL-intent LLM
|
|
11
|
+
routing - none of that is in scope for this phase).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from .commands.base import Command
|
|
19
|
+
from .models import CommandResult, IncomingMessage
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CommandDispatcher:
|
|
25
|
+
def __init__(self, command_prefix: str = "/"):
|
|
26
|
+
self.command_prefix = command_prefix
|
|
27
|
+
self._commands: dict[str, Command] = {}
|
|
28
|
+
self._aliases: dict[str, str] = {}
|
|
29
|
+
|
|
30
|
+
def register(self, command: Command) -> None:
|
|
31
|
+
name = command.name.lower()
|
|
32
|
+
if name in self._commands:
|
|
33
|
+
logger.warning("Command '%s' already registered, overwriting", name)
|
|
34
|
+
self._commands[name] = command
|
|
35
|
+
for alias in command.aliases:
|
|
36
|
+
self._aliases[alias.lower()] = name
|
|
37
|
+
|
|
38
|
+
def get_command(self, name: str) -> "Command | None":
|
|
39
|
+
name = name.lower()
|
|
40
|
+
if name in self._commands:
|
|
41
|
+
return self._commands[name]
|
|
42
|
+
if name in self._aliases:
|
|
43
|
+
return self._commands.get(self._aliases[name])
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def list_commands(self) -> list[Command]:
|
|
47
|
+
return sorted(self._commands.values(), key=lambda c: c.name)
|
|
48
|
+
|
|
49
|
+
# Reference order of the groups; anything else goes after, then "System".
|
|
50
|
+
GROUP_ORDER = ("Stock research", "AI analysis", "Markets", "Portfolio", "Trading")
|
|
51
|
+
|
|
52
|
+
def groups(self) -> dict[str, list[Command]]:
|
|
53
|
+
"""Commands by group, in registration order within a group."""
|
|
54
|
+
out: dict[str, list[Command]] = {}
|
|
55
|
+
for command in self._commands.values():
|
|
56
|
+
out.setdefault(command.group, []).append(command)
|
|
57
|
+
rank = {g: i for i, g in enumerate(self.GROUP_ORDER)}
|
|
58
|
+
return dict(sorted(out.items(), key=lambda kv: (kv[0] == "System", rank.get(kv[0], len(rank)))))
|
|
59
|
+
|
|
60
|
+
def _split(self, text: str) -> tuple[str, str]:
|
|
61
|
+
stripped = text.strip()
|
|
62
|
+
if not stripped:
|
|
63
|
+
return "", ""
|
|
64
|
+
parts = stripped.split(maxsplit=1)
|
|
65
|
+
return parts[0], (parts[1] if len(parts) > 1 else "")
|
|
66
|
+
|
|
67
|
+
def dispatch(self, message: IncomingMessage) -> CommandResult:
|
|
68
|
+
text = (message.text or "").strip()
|
|
69
|
+
if not text:
|
|
70
|
+
return CommandResult(text="", success=True)
|
|
71
|
+
|
|
72
|
+
if text.startswith(self.command_prefix):
|
|
73
|
+
head, args = self._split(text[len(self.command_prefix) :])
|
|
74
|
+
else:
|
|
75
|
+
head, args = self._split(text)
|
|
76
|
+
|
|
77
|
+
command = self.get_command(head) if head else None
|
|
78
|
+
|
|
79
|
+
if command is None:
|
|
80
|
+
# No recognized command word - if the message started with the
|
|
81
|
+
# prefix, that's a genuine unknown-command; otherwise treat the
|
|
82
|
+
# whole message as a free-form question for "ask", if present.
|
|
83
|
+
if text.startswith(self.command_prefix) or "ask" not in self._commands:
|
|
84
|
+
return CommandResult(
|
|
85
|
+
text=f"Unknown command. Try {self.command_prefix}help for a list of commands.",
|
|
86
|
+
success=False,
|
|
87
|
+
)
|
|
88
|
+
command = self._commands["ask"]
|
|
89
|
+
args = text
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
return command.handle(message, args)
|
|
93
|
+
except Exception as exc: # noqa: BLE001 - a command must never crash the listener
|
|
94
|
+
logger.exception("Command '%s' failed", command.name)
|
|
95
|
+
return CommandResult(text=f"Command failed: {exc}", success=False)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Turns a dashboard dict (marketmyna/agent/dashboard.py's build_dashboard output)
|
|
2
|
+
into a short human-readable chat message - not a raw JSON dump."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_dashboard(dashboard: dict) -> str:
|
|
8
|
+
outlook = dashboard.get("outlook")
|
|
9
|
+
lines = [f"{dashboard['symbol']} ({dashboard['market']}) - {f'{outlook} outlook' if outlook else 'AI analysis'}"]
|
|
10
|
+
|
|
11
|
+
summary = dashboard.get("analysis_summary")
|
|
12
|
+
if summary:
|
|
13
|
+
lines.append(summary)
|
|
14
|
+
|
|
15
|
+
target = dashboard.get("target_price")
|
|
16
|
+
stop = dashboard.get("stop_loss")
|
|
17
|
+
if target is not None or stop is not None:
|
|
18
|
+
lines.append(f"Target: {target if target is not None else 'n/a'} Stop: {stop if stop is not None else 'n/a'}")
|
|
19
|
+
|
|
20
|
+
if dashboard.get("technical_signal"):
|
|
21
|
+
lines.append(f"Technical: {dashboard['technical_signal']}")
|
|
22
|
+
if dashboard.get("risk_level"):
|
|
23
|
+
lines.append(f"Risk: {dashboard['risk_level']}")
|
|
24
|
+
|
|
25
|
+
return "\n".join(lines)
|