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,51 @@
|
|
|
1
|
+
"""/ask <question> - a single LLM call for a free-form question, not the
|
|
2
|
+
full agent pipeline (no tool use, no multi-turn memory in this phase).
|
|
3
|
+
|
|
4
|
+
"/research" is registered as an alias here rather than a distinct
|
|
5
|
+
command: a real deep-research capability (multi-step, tool-using, like
|
|
6
|
+
daily_stock_analysis's own research agent) is real extra scope this
|
|
7
|
+
phase doesn't build. Aliasing it to /ask is an honest "this exists but
|
|
8
|
+
is shallower than the name implies", not a silent stand-in for
|
|
9
|
+
something it isn't.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ...llm.backend_factory import create_generation_backend
|
|
15
|
+
from ...llm.protocol import GenerationConfig
|
|
16
|
+
from ..models import CommandResult, IncomingMessage
|
|
17
|
+
from .base import Command
|
|
18
|
+
|
|
19
|
+
SYSTEM_PROMPT = (
|
|
20
|
+
"You are a stock research assistant embedded in a chat bot. Answer the "
|
|
21
|
+
"user's question concisely (a few sentences). You have no live tool "
|
|
22
|
+
"access in this mode - if the question needs a live quote or a full "
|
|
23
|
+
"technical/risk analysis, say so and suggest /analyze SYMBOL instead "
|
|
24
|
+
"of guessing at numbers you don't have."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AskCommand(Command):
|
|
29
|
+
name = "ask"
|
|
30
|
+
aliases = ["research", "r"]
|
|
31
|
+
usage = "/ask QUESTION"
|
|
32
|
+
description = "Free-form question to the AI model (no live data)."
|
|
33
|
+
group = "AI analysis"
|
|
34
|
+
example = "/ask What does a falling RSI mean?"
|
|
35
|
+
help_text = "/ask <question> - ask a free-form question (also: /research, shallower than a full deep-research pass)"
|
|
36
|
+
|
|
37
|
+
def __init__(self, config):
|
|
38
|
+
self._config = config
|
|
39
|
+
|
|
40
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
41
|
+
question = args.strip()
|
|
42
|
+
if not question:
|
|
43
|
+
return CommandResult(text=f"Usage: {self.help_text}", success=False)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
backend = create_generation_backend(self._config, tier="fast")
|
|
47
|
+
result = backend.generate(question, GenerationConfig(), system_prompt=SYSTEM_PROMPT)
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
return CommandResult(text=f"Couldn't get an answer: {exc}", success=False)
|
|
50
|
+
|
|
51
|
+
return CommandResult(text=result.text.strip())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Command protocol every bot command implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from ..models import CommandResult, IncomingMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(ABC):
|
|
12
|
+
name: str = ""
|
|
13
|
+
aliases: List[str] = []
|
|
14
|
+
# Shown by /help, the web console's reference and its "/" menu.
|
|
15
|
+
usage: str = "" # "/quote SYMBOL [market]"
|
|
16
|
+
description: str = "" # one line, what it does
|
|
17
|
+
group: str = "Other" # heading in the reference (Research, Markets, ...)
|
|
18
|
+
example: str = "" # a runnable example, "/quote RELIANCE india"
|
|
19
|
+
help_text: str = "" # legacy one-liner; derived from usage + description when empty
|
|
20
|
+
|
|
21
|
+
def help_line(self) -> str:
|
|
22
|
+
if self.help_text:
|
|
23
|
+
return self.help_text
|
|
24
|
+
return f"{self.usage or '/' + self.name} - {self.description}".rstrip(" -")
|
|
25
|
+
|
|
26
|
+
def describe(self) -> dict:
|
|
27
|
+
return {"name": self.name, "aliases": list(self.aliases), "usage": self.usage or f"/{self.name}",
|
|
28
|
+
"description": self.description or self.help_line(), "group": self.group,
|
|
29
|
+
"example": self.example or self.usage or f"/{self.name}"}
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
33
|
+
"""`args` is the raw text after the command word, unparsed - each
|
|
34
|
+
command splits/validates it however it needs to."""
|
|
35
|
+
...
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""/batch SYM1,SYM2,SYM3 [market] [mode] - runs the agent pipeline over
|
|
2
|
+
several symbols sequentially (same no-concurrency approach as the
|
|
3
|
+
runtime scheduler) and replies with one combined summary."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ...agent.dashboard import build_dashboard
|
|
8
|
+
from ...agent.orchestrator import AgentOrchestrator
|
|
9
|
+
from ..formatting import format_dashboard
|
|
10
|
+
from ..models import CommandResult, IncomingMessage
|
|
11
|
+
from .base import Command
|
|
12
|
+
|
|
13
|
+
MAX_SYMBOLS = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BatchCommand(Command):
|
|
17
|
+
name = "batch"
|
|
18
|
+
aliases = ["b"]
|
|
19
|
+
usage = "/batch SYM1,SYM2 [market] [mode]"
|
|
20
|
+
description = "AI analysis of several stocks in one go (max 10)."
|
|
21
|
+
group = "AI analysis"
|
|
22
|
+
example = "/batch TCS,INFY india quick"
|
|
23
|
+
help_text = "/batch SYM1,SYM2,SYM3 [market] [mode] - run the agent pipeline over several symbols (max 10)"
|
|
24
|
+
|
|
25
|
+
def __init__(self, config):
|
|
26
|
+
self._config = config
|
|
27
|
+
|
|
28
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
29
|
+
parts = args.split()
|
|
30
|
+
if not parts:
|
|
31
|
+
return CommandResult(text=f"Usage: {self.help_text}", success=False)
|
|
32
|
+
|
|
33
|
+
symbols = [s.strip() for s in parts[0].split(",") if s.strip()]
|
|
34
|
+
if not symbols:
|
|
35
|
+
return CommandResult(text=f"Usage: {self.help_text}", success=False)
|
|
36
|
+
if len(symbols) > MAX_SYMBOLS:
|
|
37
|
+
return CommandResult(text=f"Too many symbols (max {MAX_SYMBOLS}).", success=False)
|
|
38
|
+
|
|
39
|
+
market = parts[1] if len(parts) > 1 else "us"
|
|
40
|
+
mode = parts[2] if len(parts) > 2 else "standard"
|
|
41
|
+
|
|
42
|
+
orchestrator = AgentOrchestrator(config=self._config)
|
|
43
|
+
blocks = []
|
|
44
|
+
any_ok = False
|
|
45
|
+
for symbol in symbols:
|
|
46
|
+
try:
|
|
47
|
+
ctx = orchestrator.run(symbol, market, mode=mode)
|
|
48
|
+
blocks.append(format_dashboard(build_dashboard(ctx)))
|
|
49
|
+
any_ok = True
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
blocks.append(f"{symbol}: failed ({exc})")
|
|
52
|
+
|
|
53
|
+
return CommandResult(text="\n\n".join(blocks), success=any_ok)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared parsing and number formatting for the research / market / portfolio commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
MARKETS = ("us", "india", "china_a", "hong_kong", "crypto", "global")
|
|
9
|
+
MARKET_WORDS = {"in": "india", "nse": "india", "bse": "india", "usa": "us", "hk": "hong_kong", "cn": "china_a",
|
|
10
|
+
"china": "china_a"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def market_word(token: str) -> Optional[str]:
|
|
14
|
+
t = token.lower()
|
|
15
|
+
return t if t in MARKETS else MARKET_WORDS.get(t)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_market(config) -> str:
|
|
19
|
+
"""The Trade tab's market, as the trading commands use."""
|
|
20
|
+
try:
|
|
21
|
+
from ...trading.risk import load_settings
|
|
22
|
+
|
|
23
|
+
return load_settings(config).get("market") or "india"
|
|
24
|
+
except Exception:
|
|
25
|
+
return "india"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def guess_market(symbol: str, config) -> str:
|
|
29
|
+
"""No market given: look the symbol up in the listings, preferring the
|
|
30
|
+
user's default market; fall back to the default."""
|
|
31
|
+
first = default_market(config)
|
|
32
|
+
order = [first] + [m for m in ("india", "us") if m != first]
|
|
33
|
+
if symbol.endswith("-USD"):
|
|
34
|
+
return "crypto"
|
|
35
|
+
try:
|
|
36
|
+
from ...data.listings import get_store
|
|
37
|
+
|
|
38
|
+
found = get_store().lookup([(m, kind, symbol) for m in order for kind in ("stock", "etf")])
|
|
39
|
+
except Exception:
|
|
40
|
+
return first
|
|
41
|
+
for m in order:
|
|
42
|
+
if any((m, kind, symbol) in found for kind in ("stock", "etf")):
|
|
43
|
+
return m
|
|
44
|
+
return first
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_symbol(args: str, config) -> tuple[Optional[str], str, list[str]]:
|
|
48
|
+
""""AAPL", "AAPL us", "us:AAPL", "RELIANCE india annual" ->
|
|
49
|
+
(symbol, market, remaining words). Symbol None when args are empty."""
|
|
50
|
+
parts = args.split()
|
|
51
|
+
if not parts:
|
|
52
|
+
return None, default_market(config), []
|
|
53
|
+
head, rest = parts[0], parts[1:]
|
|
54
|
+
market = None
|
|
55
|
+
if ":" in head:
|
|
56
|
+
m, _, sym = head.partition(":")
|
|
57
|
+
if market_word(m):
|
|
58
|
+
market, head = market_word(m), sym
|
|
59
|
+
if market is None and rest and market_word(rest[0]):
|
|
60
|
+
market, rest = market_word(rest[0]), rest[1:]
|
|
61
|
+
symbol = head.upper()
|
|
62
|
+
return symbol, market or guess_market(symbol, config), rest
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def num(v, digits: int = 2) -> str:
|
|
66
|
+
if v is None or (isinstance(v, float) and not math.isfinite(v)):
|
|
67
|
+
return "n/a"
|
|
68
|
+
return f"{v:,.{digits}f}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pct(v, digits: int = 1, sign: bool = True) -> str:
|
|
72
|
+
if v is None or (isinstance(v, float) and not math.isfinite(v)):
|
|
73
|
+
return "n/a"
|
|
74
|
+
return f"{v:+.{digits}f}%" if sign else f"{v:.{digits}f}%"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def big(v) -> str:
|
|
78
|
+
"""4.87e12 -> 4.87T"""
|
|
79
|
+
if v is None:
|
|
80
|
+
return "n/a"
|
|
81
|
+
for unit, size in (("T", 1e12), ("B", 1e9), ("M", 1e6), ("K", 1e3)):
|
|
82
|
+
if abs(v) >= size:
|
|
83
|
+
return f"{v / size:,.2f}{unit}"
|
|
84
|
+
return f"{v:,.0f}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""/help - lists every registered command.
|
|
2
|
+
|
|
3
|
+
Needs a reference to the dispatcher's registry, which creates an
|
|
4
|
+
ordering requirement rather than a real circular-construction problem:
|
|
5
|
+
register every other command first, then construct and register
|
|
6
|
+
HelpCommand(dispatcher) last - by then the dispatcher already holds
|
|
7
|
+
every other command, and HelpCommand just reads from it at call time
|
|
8
|
+
(not at construction time), so it also correctly lists itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..models import CommandResult, IncomingMessage
|
|
14
|
+
from .base import Command
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HelpCommand(Command):
|
|
18
|
+
name = "help"
|
|
19
|
+
aliases = ["h", "?"]
|
|
20
|
+
usage = "/help"
|
|
21
|
+
description = "List every command."
|
|
22
|
+
group = "System"
|
|
23
|
+
example = "/help"
|
|
24
|
+
help_text = "/help - list available commands"
|
|
25
|
+
|
|
26
|
+
def __init__(self, dispatcher):
|
|
27
|
+
self._dispatcher = dispatcher
|
|
28
|
+
|
|
29
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
30
|
+
want = args.strip().lstrip("/").lower()
|
|
31
|
+
if want:
|
|
32
|
+
command = self._dispatcher.get_command(want)
|
|
33
|
+
if command is None:
|
|
34
|
+
return CommandResult(text=f"No command '{want}'. /help lists them.", success=False)
|
|
35
|
+
d = command.describe()
|
|
36
|
+
aliases = f"\nAlso: {', '.join('/' + a for a in d['aliases'])}" if d["aliases"] else ""
|
|
37
|
+
return CommandResult(text=f"{d['usage']}\n{d['description']}\nExample: {d['example']}{aliases}")
|
|
38
|
+
lines = []
|
|
39
|
+
for group, commands in self._dispatcher.groups().items():
|
|
40
|
+
lines.append(f"\n{group}" if lines else group)
|
|
41
|
+
lines += [f" {c.usage or '/' + c.name} - {c.description or c.help_line()}" for c in commands]
|
|
42
|
+
lines.append("\n/help COMMAND for details and an example.")
|
|
43
|
+
return CommandResult(text="\n".join(lines))
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Market-wide views from chat: the Markets page's pulse and sector
|
|
2
|
+
rotation, the ready-made screens and the events calendar."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ...utils.deadline import call_with_deadline
|
|
7
|
+
from ..models import CommandResult, IncomingMessage
|
|
8
|
+
from .base import Command
|
|
9
|
+
from .common import default_market, market_word, num, pct
|
|
10
|
+
|
|
11
|
+
GROUP = "Markets"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _MarketCommand(Command):
|
|
15
|
+
group = GROUP
|
|
16
|
+
|
|
17
|
+
def __init__(self, config):
|
|
18
|
+
self._config = config
|
|
19
|
+
|
|
20
|
+
def _market(self, parts: list[str]) -> str:
|
|
21
|
+
return next((market_word(p) for p in parts if market_word(p)), None) or default_market(self._config)
|
|
22
|
+
|
|
23
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
24
|
+
try:
|
|
25
|
+
return CommandResult(text=self.run(args.split()))
|
|
26
|
+
except (ValueError, TimeoutError) as exc:
|
|
27
|
+
return CommandResult(text=str(exc), success=False)
|
|
28
|
+
|
|
29
|
+
def run(self, parts: list[str]) -> str:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MarketCommand(_MarketCommand):
|
|
34
|
+
name = "market"
|
|
35
|
+
aliases = ["pulse", "m"]
|
|
36
|
+
usage = "/market [market]"
|
|
37
|
+
description = "Market pulse: index levels, breadth, top gainers and losers, sector moves today."
|
|
38
|
+
example = "/market india"
|
|
39
|
+
|
|
40
|
+
def run(self, parts):
|
|
41
|
+
from ...services.discovery import DISCOVERY_MARKETS, build
|
|
42
|
+
|
|
43
|
+
market = self._market(parts)
|
|
44
|
+
if market not in DISCOVERY_MARKETS:
|
|
45
|
+
raise ValueError(f"Market pulse covers: {', '.join(DISCOVERY_MARKETS)}")
|
|
46
|
+
d = build(market, 500)
|
|
47
|
+
lines = [f"Market pulse · {market} (largest {d['universe']})"]
|
|
48
|
+
for i in d.get("indices") or []:
|
|
49
|
+
lines.append(f"{i['name']} {num(i.get('price'))} ({pct(i.get('change_percent'), 2)})")
|
|
50
|
+
b = d.get("breadth") or {}
|
|
51
|
+
if b.get("total"):
|
|
52
|
+
lines.append(f"Breadth: {b['advancers']} up / {b['decliners']} down of {b['total']}")
|
|
53
|
+
for key, title in (("gainers", "Top gainers"), ("losers", "Top losers")):
|
|
54
|
+
rows = (d.get(key) or [])[:5]
|
|
55
|
+
if rows:
|
|
56
|
+
lines.append(f"{title}: " + ", ".join(f"{r['symbol']} {pct(r.get('change_percent'))}" for r in rows))
|
|
57
|
+
secs = d.get("sectors") or []
|
|
58
|
+
if secs:
|
|
59
|
+
lines.append("Sectors: " + ", ".join(f"{s['sector']} {pct(s.get('change_percent'), 2)}" for s in secs[:4])
|
|
60
|
+
+ " … " + ", ".join(f"{s['sector']} {pct(s.get('change_percent'), 2)}" for s in secs[-3:]))
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SectorsCommand(_MarketCommand):
|
|
65
|
+
name = "sectors"
|
|
66
|
+
aliases = ["rotation", "rrg"]
|
|
67
|
+
usage = "/sectors [market]"
|
|
68
|
+
description = "Sector rotation: which sectors are leading, weakening, lagging or improving vs the index."
|
|
69
|
+
example = "/sectors india"
|
|
70
|
+
|
|
71
|
+
def run(self, parts):
|
|
72
|
+
from ...services.rotation import ROTATION_MARKETS, build
|
|
73
|
+
|
|
74
|
+
market = self._market(parts)
|
|
75
|
+
if market not in ROTATION_MARKETS:
|
|
76
|
+
raise ValueError(f"Sector rotation covers: {', '.join(ROTATION_MARKETS)}")
|
|
77
|
+
d = call_with_deadline(build, 90, market)
|
|
78
|
+
lines = [f"Sector rotation vs {d['benchmark']['name']}, weekly to {d.get('as_of')}"]
|
|
79
|
+
for q, title in (("leading", "Leading"), ("improving", "Improving"), ("weakening", "Weakening"), ("lagging", "Lagging")):
|
|
80
|
+
rows = [s for s in d.get("sectors") or [] if s.get("quadrant") == q]
|
|
81
|
+
if rows:
|
|
82
|
+
lines.append(f"{title}: " + ", ".join(f"{s['sector']} ({pct(s.get('return_4w'))} 4w)" for s in rows))
|
|
83
|
+
lines.append("Cycle: improving → leading → weakening → lagging.")
|
|
84
|
+
return "\n".join(lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ScreenCommand(_MarketCommand):
|
|
88
|
+
name = "screen"
|
|
89
|
+
aliases = ["screens"]
|
|
90
|
+
usage = "/screen [KEY] [india|us]"
|
|
91
|
+
description = "List the ready-made fundamental screens, or run one over the largest stocks."
|
|
92
|
+
example = "/screen undervalued_large india"
|
|
93
|
+
|
|
94
|
+
def run(self, parts):
|
|
95
|
+
from ...services.screens import PRESETS, list_presets, run
|
|
96
|
+
|
|
97
|
+
keys = [p for p in parts if not market_word(p)]
|
|
98
|
+
if not keys:
|
|
99
|
+
lines = ["Screens (run one with /screen KEY [india|us]):"]
|
|
100
|
+
lines += [f"• {p['key']}: {p['title']} - {p['description']}" for p in list_presets()]
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
key = keys[0].lower()
|
|
103
|
+
if key not in PRESETS:
|
|
104
|
+
match = [k for k in PRESETS if key in k]
|
|
105
|
+
if len(match) != 1:
|
|
106
|
+
raise ValueError(f"Unknown screen '{key}'. /screen lists them.")
|
|
107
|
+
key = match[0]
|
|
108
|
+
market = self._market(parts)
|
|
109
|
+
if market not in ("india", "us"):
|
|
110
|
+
raise ValueError("Screens cover the India and US markets.")
|
|
111
|
+
d = call_with_deadline(run, 90, key, market)
|
|
112
|
+
rows = d.get("results") or []
|
|
113
|
+
lines = [f"{d['title']} ({market}): {len(rows)} of {d.get('scanned')} stocks pass",
|
|
114
|
+
"Rules: " + "; ".join(d.get("rules") or [])]
|
|
115
|
+
for r in rows[:15]:
|
|
116
|
+
lines.append(f"• {r['symbol']} {num(r.get('price'))} · P/E {num(r.get('pe'), 1)} · "
|
|
117
|
+
f"ROE {pct(r.get('roe'), 1, False)}")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CalendarCommand(_MarketCommand):
|
|
122
|
+
name = "calendar"
|
|
123
|
+
aliases = ["events", "cal"]
|
|
124
|
+
usage = "/calendar [DAYS]"
|
|
125
|
+
description = "Upcoming results and dividends for stocks you follow, plus central bank and economic events and market holidays."
|
|
126
|
+
example = "/calendar 14"
|
|
127
|
+
|
|
128
|
+
def run(self, parts):
|
|
129
|
+
from datetime import datetime
|
|
130
|
+
from zoneinfo import ZoneInfo
|
|
131
|
+
|
|
132
|
+
from ...services import econ_calendar, market_calendar
|
|
133
|
+
from ...services.calendar import collect, followed_symbols, load_settings
|
|
134
|
+
|
|
135
|
+
days = next((int(p) for p in parts if p.isdigit()), 14)
|
|
136
|
+
days = max(1, min(days, 120))
|
|
137
|
+
settings = load_settings(self._config)
|
|
138
|
+
tz = settings.get("timezone") or "Asia/Kolkata"
|
|
139
|
+
today = datetime.now(ZoneInfo(tz)).date()
|
|
140
|
+
events = collect(followed_symbols(self._config), today, days)
|
|
141
|
+
econ = econ_calendar.collect(today, days)["events"]
|
|
142
|
+
markets = [e for e in market_calendar.collect(today, days)["events"]
|
|
143
|
+
if e["key"] in ("market_holiday", "short_day", "special_session")]
|
|
144
|
+
lines = [f"Next {days} days"]
|
|
145
|
+
for e in econ[:10]:
|
|
146
|
+
lines.append(f"• {e['date']} {econ_calendar.local_time(e, tz) or ''} {e['title']} ({e.get('region')})".replace(" ", " "))
|
|
147
|
+
for e in markets[:10]:
|
|
148
|
+
lines.append(f"• {e['date']} {e['title']}")
|
|
149
|
+
for e in events[:15]:
|
|
150
|
+
lines.append(f"• {e['date']} {e['symbol']} ({e['market']}): {e['label']}")
|
|
151
|
+
if len(lines) == 1:
|
|
152
|
+
lines.append("Nothing scheduled. Add stocks to a watchlist or portfolio to track their results.")
|
|
153
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Your own data from chat: portfolio value and holdings, the portfolio
|
|
2
|
+
review (marketmyna/portfolio/review.py) and watchlists. In accounts mode the
|
|
3
|
+
caller's tenant scope (web request or the linked Telegram chat) limits
|
|
4
|
+
every query to that user's rows."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..models import CommandResult, IncomingMessage
|
|
9
|
+
from .base import Command
|
|
10
|
+
from .common import big, num, pct
|
|
11
|
+
|
|
12
|
+
GROUP = "Portfolio"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _manager(config):
|
|
16
|
+
from ...portfolio.manager import PortfolioManager
|
|
17
|
+
|
|
18
|
+
return PortfolioManager(config.data_dir, config=config)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pick(manager, args: str) -> tuple[str | None, str]:
|
|
22
|
+
"""Portfolio by (case-insensitive, prefix) name; the first one when no name is given."""
|
|
23
|
+
names = manager.get_all_portfolios()
|
|
24
|
+
if not names:
|
|
25
|
+
return None, "No portfolios yet. Create one on the Portfolio page."
|
|
26
|
+
want = args.strip().lower()
|
|
27
|
+
if not want:
|
|
28
|
+
return names[0], ""
|
|
29
|
+
match = next((n for n in names if n.lower() == want), None) or next((n for n in names if n.lower().startswith(want)), None)
|
|
30
|
+
if match is None:
|
|
31
|
+
return None, f"No portfolio '{args.strip()}'. You have: {', '.join(names)}"
|
|
32
|
+
return match, ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PortfolioCommand(Command):
|
|
36
|
+
name = "portfolio"
|
|
37
|
+
aliases = ["pf", "holdings"]
|
|
38
|
+
usage = "/portfolio [NAME]"
|
|
39
|
+
description = "Portfolio value, profit / loss and every holding with its weight and return."
|
|
40
|
+
example = "/portfolio"
|
|
41
|
+
group = GROUP
|
|
42
|
+
|
|
43
|
+
def __init__(self, config):
|
|
44
|
+
self._config = config
|
|
45
|
+
|
|
46
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
47
|
+
manager = _manager(self._config)
|
|
48
|
+
name, error = _pick(manager, args)
|
|
49
|
+
if not name:
|
|
50
|
+
return CommandResult(text=error, success=not args.strip())
|
|
51
|
+
s = manager.get_portfolio_summary(name)
|
|
52
|
+
cur = s.get("currency") or ""
|
|
53
|
+
holdings = sorted(s["holdings"].values(), key=lambda h: -(h.get("market_value") or 0))
|
|
54
|
+
total = s.get("total_market_value") or 0
|
|
55
|
+
lines = [f"{name} · {cur} {num(s.get('total_value'))} "
|
|
56
|
+
f"(invested {num(s.get('total_cost_basis'))}, P/L {num(s.get('total_unrealized_gain'))} "
|
|
57
|
+
f"{pct(s.get('total_unrealized_gain_percent'))})"]
|
|
58
|
+
if s.get("cash_balance"):
|
|
59
|
+
lines.append(f"Cash {num(s['cash_balance'])}")
|
|
60
|
+
if not holdings:
|
|
61
|
+
lines.append("No holdings yet.")
|
|
62
|
+
for h in holdings[:25]:
|
|
63
|
+
w = (h.get("market_value") or 0) / total * 100 if total else 0
|
|
64
|
+
lines.append(f"• {h['symbol']} ({h.get('market')}) {num(h.get('quantity'), 4).rstrip('0').rstrip('.')} × "
|
|
65
|
+
f"{num(h.get('current_price'))} = {big(h.get('market_value'))} · {w:.1f}% · "
|
|
66
|
+
f"{pct(h.get('unrealized_gain_percent'))}")
|
|
67
|
+
if len(holdings) > 25:
|
|
68
|
+
lines.append(f"… and {len(holdings) - 25} more")
|
|
69
|
+
others = [n for n in manager.get_all_portfolios() if n != name]
|
|
70
|
+
if others:
|
|
71
|
+
lines.append(f"Other portfolios: {', '.join(others)}")
|
|
72
|
+
return CommandResult(text="\n".join(lines))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ReviewCommand(Command):
|
|
76
|
+
name = "review"
|
|
77
|
+
aliases = ["checkup"]
|
|
78
|
+
usage = "/review [NAME]"
|
|
79
|
+
description = "Portfolio review: what could be changed, why it matters and how, checked against your limits."
|
|
80
|
+
example = "/review"
|
|
81
|
+
group = GROUP
|
|
82
|
+
|
|
83
|
+
def __init__(self, config):
|
|
84
|
+
self._config = config
|
|
85
|
+
|
|
86
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
87
|
+
from ...portfolio.review import for_portfolio
|
|
88
|
+
|
|
89
|
+
manager = _manager(self._config)
|
|
90
|
+
name, error = _pick(manager, args)
|
|
91
|
+
if not name:
|
|
92
|
+
return CommandResult(text=error, success=not args.strip())
|
|
93
|
+
r = for_portfolio(manager, name, self._config)
|
|
94
|
+
if r.get("score") is None:
|
|
95
|
+
return CommandResult(text=f"{name}: {r.get('summary')}")
|
|
96
|
+
lines = [f"{name} review: {r['score']}/100 ({r['grade']}) · {r['summary']}"]
|
|
97
|
+
marks = {"high": "🔴", "medium": "🟠", "low": "⚪"}
|
|
98
|
+
for i, f in enumerate(r["findings"], 1):
|
|
99
|
+
lines.append(f"\n{marks[f['severity']]} {i}. {f['title']}" + (f" ({f['rule']})" if f.get("rule") else ""))
|
|
100
|
+
lines.append(f" {f['fact']}")
|
|
101
|
+
lines.append(f" Why: {f['why']}")
|
|
102
|
+
for lever in f["levers"]:
|
|
103
|
+
lines.append(f" → {lever}")
|
|
104
|
+
if r.get("good"):
|
|
105
|
+
lines.append("\nWorking well:")
|
|
106
|
+
lines += [f"✓ {g}" for g in r["good"]]
|
|
107
|
+
return CommandResult(text="\n".join(lines))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WatchlistCommand(Command):
|
|
111
|
+
name = "watchlist"
|
|
112
|
+
aliases = ["wl", "watch"]
|
|
113
|
+
usage = "/watchlist [NAME]"
|
|
114
|
+
description = "Your watchlists, or one list's stocks with price and day change."
|
|
115
|
+
example = "/watchlist"
|
|
116
|
+
group = GROUP
|
|
117
|
+
|
|
118
|
+
def __init__(self, config):
|
|
119
|
+
self._config = config
|
|
120
|
+
|
|
121
|
+
def handle(self, message: IncomingMessage, args: str) -> CommandResult:
|
|
122
|
+
from ...data.listings import get_store
|
|
123
|
+
from ...storage import DatabaseManager, WatchlistRecord, get_db_url
|
|
124
|
+
|
|
125
|
+
with DatabaseManager.instance(get_db_url(config=self._config)).get_session() as session:
|
|
126
|
+
lists = session.query(WatchlistRecord).order_by(WatchlistRecord.id).all()
|
|
127
|
+
if not lists:
|
|
128
|
+
return CommandResult(text="No watchlists yet. Star a stock on Markets or Analyze to start one.")
|
|
129
|
+
want = args.strip().lower()
|
|
130
|
+
record = (next((w for w in lists if w.name.lower() == want), None)
|
|
131
|
+
or next((w for w in lists if w.name.lower().startswith(want)), None)) if want else (
|
|
132
|
+
lists[0] if len(lists) == 1 else None)
|
|
133
|
+
if record is None:
|
|
134
|
+
if want:
|
|
135
|
+
return CommandResult(text=f"No watchlist '{args.strip()}'.", success=False)
|
|
136
|
+
return CommandResult(text="Your watchlists:\n" + "\n".join(
|
|
137
|
+
f"• {w.name} ({len(w.items)} stocks)" for w in lists) + "\nShow one with /watchlist NAME")
|
|
138
|
+
items = [(i.symbol, i.market, i.type) for i in record.items]
|
|
139
|
+
title = record.name
|
|
140
|
+
live = get_store().lookup([(m, t, s) for s, m, t in items]) if items else {}
|
|
141
|
+
lines = [f"{title} ({len(items)})"]
|
|
142
|
+
for s, m, t in items[:40]:
|
|
143
|
+
row = live.get((m, t, s)) or {}
|
|
144
|
+
lines.append(f"• {s} ({m}) {num(row.get('price'))} {pct(row.get('change_percent'))}" if row else f"• {s} ({m})")
|
|
145
|
+
return CommandResult(text="\n".join(lines))
|