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
marketmyna/bot/models.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Platform-agnostic message shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class IncomingMessage:
|
|
11
|
+
"""One message from any platform, normalized to the same shape
|
|
12
|
+
before it reaches CommandDispatcher."""
|
|
13
|
+
|
|
14
|
+
platform: str
|
|
15
|
+
chat_id: str
|
|
16
|
+
text: str
|
|
17
|
+
user_id: Optional[str] = None
|
|
18
|
+
raw: Any = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CommandResult:
|
|
23
|
+
"""A command's response - plain text, no platform-specific
|
|
24
|
+
formatting (markdown/embeds/etc are each listener's own concern)."""
|
|
25
|
+
|
|
26
|
+
text: str
|
|
27
|
+
success: bool = True
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Builds a CommandDispatcher with every command registered - the one
|
|
2
|
+
place that ordering (HelpCommand last, see commands/help.py) is handled,
|
|
3
|
+
so callers (marketmyna/api/app.py's lifespan, tests, a future CLI) don't each have
|
|
4
|
+
to get it right themselves."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from .commands.analyze import AnalyzeCommand
|
|
11
|
+
from .commands.ask import AskCommand
|
|
12
|
+
from .commands.batch import BatchCommand
|
|
13
|
+
from .commands.help import HelpCommand
|
|
14
|
+
from .commands.markets import CalendarCommand, MarketCommand, ScreenCommand, SectorsCommand
|
|
15
|
+
from .commands.portfolio import PortfolioCommand, ReviewCommand, WatchlistCommand
|
|
16
|
+
from .commands.research import (
|
|
17
|
+
CompareCommand,
|
|
18
|
+
DividendsCommand,
|
|
19
|
+
EstimatesCommand,
|
|
20
|
+
FinancialsCommand,
|
|
21
|
+
OverviewCommand,
|
|
22
|
+
PeersCommand,
|
|
23
|
+
QuoteCommand,
|
|
24
|
+
ShariahCommand,
|
|
25
|
+
ValuationCommand,
|
|
26
|
+
)
|
|
27
|
+
from .commands.status import StatusCommand
|
|
28
|
+
from .commands.trade import AlertCommand, AlertsCommand, RiskCommand, ScanCommand
|
|
29
|
+
from .dispatcher import CommandDispatcher
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_default_dispatcher(config, scheduler_running: Optional[Callable[[], bool]] = None) -> CommandDispatcher:
|
|
33
|
+
dispatcher = CommandDispatcher()
|
|
34
|
+
for research in (QuoteCommand, OverviewCommand, ValuationCommand, FinancialsCommand, EstimatesCommand,
|
|
35
|
+
DividendsCommand, PeersCommand, ShariahCommand, CompareCommand):
|
|
36
|
+
dispatcher.register(research(config))
|
|
37
|
+
for market in (MarketCommand, SectorsCommand, ScreenCommand, CalendarCommand):
|
|
38
|
+
dispatcher.register(market(config))
|
|
39
|
+
for own in (PortfolioCommand, ReviewCommand, WatchlistCommand):
|
|
40
|
+
dispatcher.register(own(config))
|
|
41
|
+
dispatcher.register(AnalyzeCommand(config))
|
|
42
|
+
dispatcher.register(BatchCommand(config))
|
|
43
|
+
dispatcher.register(AskCommand(config))
|
|
44
|
+
dispatcher.register(StatusCommand(config, scheduler_running=scheduler_running))
|
|
45
|
+
dispatcher.register(AlertCommand(config))
|
|
46
|
+
dispatcher.register(AlertsCommand(config))
|
|
47
|
+
dispatcher.register(RiskCommand(config))
|
|
48
|
+
dispatcher.register(ScanCommand(config))
|
|
49
|
+
dispatcher.register(HelpCommand(dispatcher))
|
|
50
|
+
return dispatcher
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Real (not stubbed) inbound Telegram listener: long-polls getUpdates,
|
|
2
|
+
routes each message through a CommandDispatcher, replies via Telegram's
|
|
3
|
+
sendMessage - the one platform with genuine two-way bot support in this
|
|
4
|
+
phase. The 13 other notifier channels in marketmyna/notifiers/ remain outbound
|
|
5
|
+
(alert-push) only; each would need its own listener (webhook or poll,
|
|
6
|
+
depending on the platform) as separate follow-up work.
|
|
7
|
+
|
|
8
|
+
Reuses config.telegram_bot_token (the same credential marketmyna/notifiers/
|
|
9
|
+
telegram.py's outbound TelegramSender uses) rather than a new field.
|
|
10
|
+
Does NOT reuse TelegramSender itself for replies: that sender's chat_id
|
|
11
|
+
is fixed at construction (one configured alert destination), but a bot
|
|
12
|
+
must reply to whichever chat_id sent the incoming message, which can
|
|
13
|
+
differ - so this calls Telegram's sendMessage directly with the
|
|
14
|
+
per-message chat_id instead of force-fitting the wrong abstraction.
|
|
15
|
+
|
|
16
|
+
Same start()/stop()/is_running() shape as RuntimeSchedulerService
|
|
17
|
+
(marketmyna/services/runtime_scheduler.py) for consistency across the app's
|
|
18
|
+
background services.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
|
|
29
|
+
from .dispatcher import CommandDispatcher
|
|
30
|
+
from .models import IncomingMessage
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
API_ROOT = "https://api.telegram.org"
|
|
35
|
+
POLL_TIMEOUT_SECONDS = 30
|
|
36
|
+
REQUEST_TIMEOUT_SECONDS = 35 # a bit more than the long-poll timeout itself
|
|
37
|
+
ERROR_BACKOFF_SECONDS = 5.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
IDLE_CHECK_SECONDS = 10.0 # how often to look for a newly saved token
|
|
41
|
+
|
|
42
|
+
SETUP_REPLY = (
|
|
43
|
+
"👋 This is the MarketMyna bot.\n\n"
|
|
44
|
+
"Your chat ID is {chat_id} - paste it into Settings → Notifications → Telegram → Chat ID."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def allowed_chats(config) -> set[str]:
|
|
49
|
+
"""Chats the bot obeys: Settings → Telegram → Chat ID (comma-separated for
|
|
50
|
+
several, e.g. you and a group)."""
|
|
51
|
+
raw = str(getattr(config, "telegram_chat_id", None) or "")
|
|
52
|
+
return {c.strip() for c in raw.split(",") if c.strip()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TelegramListener:
|
|
56
|
+
"""Long-polls getUpdates and answers commands - only from the chats in
|
|
57
|
+
Settings' Chat ID. Anyone else (and you, before a Chat ID is saved)
|
|
58
|
+
just gets told their chat ID, so the bot can't be used by strangers
|
|
59
|
+
who find its username.
|
|
60
|
+
|
|
61
|
+
Reads the token from the shared Config on every poll, so saving a
|
|
62
|
+
token in Settings takes effect without restarting the server."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, config, dispatcher: CommandDispatcher):
|
|
65
|
+
self._config = config
|
|
66
|
+
self._dispatcher = dispatcher
|
|
67
|
+
self._last_update_id: Optional[int] = None
|
|
68
|
+
self._token_in_use: Optional[str] = None
|
|
69
|
+
self._stop_event = threading.Event()
|
|
70
|
+
self._thread: Optional[threading.Thread] = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def _bot_token(self) -> Optional[str]:
|
|
74
|
+
return getattr(self._config, "telegram_bot_token", None)
|
|
75
|
+
|
|
76
|
+
def is_configured(self) -> bool:
|
|
77
|
+
return bool(self._bot_token)
|
|
78
|
+
|
|
79
|
+
# ---- lifecycle ----
|
|
80
|
+
|
|
81
|
+
def start(self) -> None:
|
|
82
|
+
if self._thread is not None and self._thread.is_alive():
|
|
83
|
+
return
|
|
84
|
+
if not self.is_configured():
|
|
85
|
+
logger.info("TelegramListener waiting: no telegram_bot_token yet (picked up once saved in Settings)")
|
|
86
|
+
self._stop_event.clear()
|
|
87
|
+
self._thread = threading.Thread(target=self._loop, name="marketmyna-telegram-listener", daemon=True)
|
|
88
|
+
self._thread.start()
|
|
89
|
+
|
|
90
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
91
|
+
self._stop_event.set()
|
|
92
|
+
if self._thread is not None:
|
|
93
|
+
self._thread.join(timeout=timeout)
|
|
94
|
+
|
|
95
|
+
def is_running(self) -> bool:
|
|
96
|
+
return self._thread is not None and self._thread.is_alive()
|
|
97
|
+
|
|
98
|
+
# ---- polling loop ----
|
|
99
|
+
|
|
100
|
+
def _loop(self) -> None:
|
|
101
|
+
while not self._stop_event.is_set():
|
|
102
|
+
if not self._bot_token:
|
|
103
|
+
self._stop_event.wait(IDLE_CHECK_SECONDS)
|
|
104
|
+
continue
|
|
105
|
+
if self._bot_token != self._token_in_use: # new or changed bot: start from its latest update
|
|
106
|
+
self._token_in_use = self._bot_token
|
|
107
|
+
self._last_update_id = None
|
|
108
|
+
self._publish_commands()
|
|
109
|
+
try:
|
|
110
|
+
self._poll_once()
|
|
111
|
+
except Exception:
|
|
112
|
+
logger.exception("Telegram poll failed, backing off")
|
|
113
|
+
self._stop_event.wait(ERROR_BACKOFF_SECONDS)
|
|
114
|
+
|
|
115
|
+
def _poll_once(self) -> None:
|
|
116
|
+
params = {"timeout": POLL_TIMEOUT_SECONDS}
|
|
117
|
+
if self._last_update_id is not None:
|
|
118
|
+
params["offset"] = self._last_update_id + 1
|
|
119
|
+
|
|
120
|
+
resp = requests.get(
|
|
121
|
+
f"{API_ROOT}/bot{self._bot_token}/getUpdates",
|
|
122
|
+
params=params,
|
|
123
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
124
|
+
)
|
|
125
|
+
resp.raise_for_status()
|
|
126
|
+
payload = resp.json()
|
|
127
|
+
if not payload.get("ok"):
|
|
128
|
+
logger.warning("Telegram getUpdates returned ok=false: %s", payload)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
for update in payload.get("result", []):
|
|
132
|
+
update_id = update.get("update_id")
|
|
133
|
+
if update_id is not None:
|
|
134
|
+
self._last_update_id = update_id
|
|
135
|
+
|
|
136
|
+
message = update.get("message")
|
|
137
|
+
if not message or "text" not in message:
|
|
138
|
+
continue # edited_message/channel_post/non-text - skip, not an error
|
|
139
|
+
|
|
140
|
+
self._handle_message(message)
|
|
141
|
+
|
|
142
|
+
def _handle_message(self, message: dict) -> None:
|
|
143
|
+
from marketmyna.core import tenant
|
|
144
|
+
|
|
145
|
+
with tenant.system_scope():
|
|
146
|
+
self._handle_message_scoped(message)
|
|
147
|
+
|
|
148
|
+
def _command_scope(self, chat_id: str, is_owner: bool):
|
|
149
|
+
"""Commands run as the owner (only the owner's chats get this far)."""
|
|
150
|
+
import contextlib
|
|
151
|
+
|
|
152
|
+
from marketmyna.core import tenant
|
|
153
|
+
from marketmyna.services.accounts import owner_scope
|
|
154
|
+
|
|
155
|
+
if not tenant.enabled():
|
|
156
|
+
return contextlib.nullcontext()
|
|
157
|
+
return owner_scope(self._config)
|
|
158
|
+
|
|
159
|
+
def _handle_message_scoped(self, message: dict) -> None:
|
|
160
|
+
chat_id = str(message["chat"]["id"])
|
|
161
|
+
text = message.get("text", "")
|
|
162
|
+
word = text.strip().split(" ")[0].split("@")[0]
|
|
163
|
+
|
|
164
|
+
is_owner = chat_id in allowed_chats(self._config)
|
|
165
|
+
if not is_owner:
|
|
166
|
+
# Not the owner's chat: never run commands, just explain how to connect.
|
|
167
|
+
if word in ("/start", "/id", "/help") or not allowed_chats(self._config):
|
|
168
|
+
self._reply(chat_id, SETUP_REPLY.format(chat_id=chat_id))
|
|
169
|
+
else:
|
|
170
|
+
logger.info("Ignored Telegram message from unauthorised chat %s", chat_id)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
user = message.get("from") or {}
|
|
174
|
+
incoming = IncomingMessage(
|
|
175
|
+
platform="telegram",
|
|
176
|
+
chat_id=chat_id,
|
|
177
|
+
text=text,
|
|
178
|
+
user_id=str(user["id"]) if "id" in user else None,
|
|
179
|
+
raw=message,
|
|
180
|
+
)
|
|
181
|
+
if word in ("/start", "/id"):
|
|
182
|
+
self._reply(chat_id, f"✅ Connected to MarketMyna. Chat ID {chat_id}.\nSend /help for commands.")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
with self._command_scope(chat_id, is_owner):
|
|
186
|
+
result = self._dispatcher.dispatch(incoming)
|
|
187
|
+
if result.text:
|
|
188
|
+
self._reply(chat_id, result.text)
|
|
189
|
+
|
|
190
|
+
def _publish_commands(self) -> None:
|
|
191
|
+
"""Telegram's own "/" menu: the registered commands with their one-line descriptions."""
|
|
192
|
+
commands = [{"command": c.name, "description": (c.description or c.help_line())[:256]}
|
|
193
|
+
for c in self._dispatcher.list_commands()][:100]
|
|
194
|
+
try:
|
|
195
|
+
requests.post(f"{API_ROOT}/bot{self._bot_token}/setMyCommands", json={"commands": commands},
|
|
196
|
+
timeout=REQUEST_TIMEOUT_SECONDS)
|
|
197
|
+
except requests.RequestException:
|
|
198
|
+
logger.warning("Could not publish the Telegram command menu")
|
|
199
|
+
|
|
200
|
+
def _reply(self, chat_id: str, text: str) -> None:
|
|
201
|
+
for part in split_message(text):
|
|
202
|
+
try:
|
|
203
|
+
requests.post(
|
|
204
|
+
f"{API_ROOT}/bot{self._bot_token}/sendMessage",
|
|
205
|
+
json={"chat_id": chat_id, "text": part},
|
|
206
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
207
|
+
)
|
|
208
|
+
except requests.RequestException:
|
|
209
|
+
logger.exception("Failed to send Telegram reply to chat %s", chat_id)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def split_message(text: str, limit: int = 4000) -> list[str]:
|
|
214
|
+
"""Telegram caps a message at 4096 characters: split on line breaks."""
|
|
215
|
+
parts, current = [], ""
|
|
216
|
+
for line in text.split("\n"):
|
|
217
|
+
while len(line) > limit:
|
|
218
|
+
parts.append(line[:limit])
|
|
219
|
+
line = line[limit:]
|
|
220
|
+
if current and len(current) + 1 + len(line) > limit:
|
|
221
|
+
parts.append(current)
|
|
222
|
+
current = line
|
|
223
|
+
else:
|
|
224
|
+
current = f"{current}\n{line}" if current else line
|
|
225
|
+
if current:
|
|
226
|
+
parts.append(current)
|
|
227
|
+
return parts
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line entry points (headless runs, e.g. from cron)."""
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Headless scheduled analysis: analyze a stock list, then push the report
|
|
2
|
+
to every configured notification channel.
|
|
3
|
+
|
|
4
|
+
Runs anywhere - cron, a server, your laptop:
|
|
5
|
+
|
|
6
|
+
python -m marketmyna.cli.scheduled_run --symbols "RELIANCE,TCS,us:AAPL" --market india
|
|
7
|
+
python -m marketmyna.cli.scheduled_run --watchlist "My watchlist" --dry-run
|
|
8
|
+
|
|
9
|
+
Everything can also come from environment variables, which is how the
|
|
10
|
+
workflow passes GitHub Secrets/Variables in:
|
|
11
|
+
|
|
12
|
+
STOCK_LIST symbols, comma-separated; "market:SYMBOL" overrides the market
|
|
13
|
+
MARKET default market for unprefixed symbols (default: india)
|
|
14
|
+
ANALYSIS_MODE quick | standard | full | specialist (default: standard)
|
|
15
|
+
REPORT_TIMEZONE timestamp timezone in the report (default: Asia/Kolkata)
|
|
16
|
+
TRADING_DAY_CHECK_ENABLED "false" to also run on weekends
|
|
17
|
+
REPORT_LANGUAGE default report language code, e.g. en, hi, ta, zh (default: en)
|
|
18
|
+
REPORT_SECTIONS comma-separated section ids (see marketmyna/services/report.py SECTIONS)
|
|
19
|
+
STOCK_SECTIONS per-stock sections: "RELIANCE=news+fundamentals,TCS=technical"
|
|
20
|
+
CHANNEL_LANGUAGES per-channel language: "telegram:ta,email:en"
|
|
21
|
+
SMART_MESSAGING "true": short top-N message + full report (HTML email/file)
|
|
22
|
+
SMART_TOP_N stocks in the short message (default 5)
|
|
23
|
+
SMART_AI_TOP stocks that get the AI pipeline (default 10; 0 = none)
|
|
24
|
+
SMART_EMAIL_FULL "false": email gets only the short message
|
|
25
|
+
REPORT_BASE_URL public MarketMyna address for full-report links
|
|
26
|
+
MF_LIST AMFI scheme codes for the mutual-fund section: "122639,120503"
|
|
27
|
+
+ LLM_* and every notification channel field (TELEGRAM_BOT_TOKEN, ...),
|
|
28
|
+
read by Config.apply_env().
|
|
29
|
+
|
|
30
|
+
Exit codes: 0 = report produced (and delivered, if any channel is set),
|
|
31
|
+
1 = nothing to analyze or every symbol failed outright,
|
|
32
|
+
2 = channels are configured but none accepted the message.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from zoneinfo import ZoneInfo
|
|
44
|
+
|
|
45
|
+
from ..core.config import Config
|
|
46
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
47
|
+
from ..services.analysis_run import (
|
|
48
|
+
MARKET_LABELS,
|
|
49
|
+
analyze_symbol,
|
|
50
|
+
deliver_report,
|
|
51
|
+
is_trading_day,
|
|
52
|
+
notify_failure,
|
|
53
|
+
parse_targets,
|
|
54
|
+
)
|
|
55
|
+
from ..services.report import ReportPrefs, clean_sections
|
|
56
|
+
from ..services.report_i18n import normalize_language
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger("marketmyna.scheduled_run")
|
|
59
|
+
|
|
60
|
+
MODES = ("quick", "standard", "full", "specialist")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _env_flag(name: str, default: bool) -> bool:
|
|
64
|
+
value = os.environ.get(name, "").strip().lower()
|
|
65
|
+
if not value:
|
|
66
|
+
return default
|
|
67
|
+
return value in ("1", "true", "yes", "on")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _watchlist_symbols(config: Config, name: str) -> str:
|
|
71
|
+
"""Symbols from a saved in-app watchlist, as a STOCK_LIST-style spec."""
|
|
72
|
+
from ..storage import DatabaseManager, WatchlistRecord, get_db_url
|
|
73
|
+
|
|
74
|
+
db = DatabaseManager.instance(get_db_url(config=config))
|
|
75
|
+
with db.get_session() as session:
|
|
76
|
+
record = session.query(WatchlistRecord).filter(WatchlistRecord.name == name).first()
|
|
77
|
+
if record is None:
|
|
78
|
+
raise SystemExit(f"No watchlist named {name!r}")
|
|
79
|
+
# Funds have no price-chart analysis; skip them.
|
|
80
|
+
return ",".join(f"{i.market}:{i.symbol}" for i in record.items if i.type != "mf")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _saved_prefs(config: Config) -> ReportPrefs | None:
|
|
84
|
+
"""Report preferences saved on the Scheduler page, if this machine has
|
|
85
|
+
the app database (a server run); a fresh GitHub runner has none."""
|
|
86
|
+
try:
|
|
87
|
+
from ..services.schedule_config import ScheduleConfigStore
|
|
88
|
+
from ..storage import has_app_db
|
|
89
|
+
|
|
90
|
+
if not has_app_db(config):
|
|
91
|
+
return None
|
|
92
|
+
return ScheduleConfigStore(config=config).load().report_prefs()
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
logger.warning("Saved report preferences unreadable: %s", exc)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_pairs(raw: str, sep: str) -> dict[str, str]:
|
|
99
|
+
""""telegram:ta,email:en" -> {"telegram": "ta", "email": "en"}."""
|
|
100
|
+
out = {}
|
|
101
|
+
for part in raw.split(","):
|
|
102
|
+
if sep in part:
|
|
103
|
+
key, value = part.split(sep, 1)
|
|
104
|
+
if key.strip() and value.strip():
|
|
105
|
+
out[key.strip()] = value.strip()
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_list(raw: str) -> list[str]:
|
|
110
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_prefs(
|
|
114
|
+
config: Config, mode: str, timezone: str, language: str | None = None
|
|
115
|
+
) -> ReportPrefs:
|
|
116
|
+
"""Saved Scheduler preferences, overridden by env vars / flags."""
|
|
117
|
+
prefs = _saved_prefs(config)
|
|
118
|
+
if prefs is None:
|
|
119
|
+
prefs = ReportPrefs()
|
|
120
|
+
prefs.mode, prefs.timezone = mode, timezone
|
|
121
|
+
if language or os.environ.get("REPORT_LANGUAGE", "").strip():
|
|
122
|
+
prefs.language = normalize_language(language or os.environ["REPORT_LANGUAGE"])
|
|
123
|
+
if os.environ.get("REPORT_SECTIONS", "").strip():
|
|
124
|
+
prefs.sections = clean_sections(os.environ["REPORT_SECTIONS"].split(","))
|
|
125
|
+
if os.environ.get("STOCK_SECTIONS", "").strip():
|
|
126
|
+
prefs.stock_sections = {
|
|
127
|
+
sym.upper(): clean_sections(secs.replace("|", "+").split("+"))
|
|
128
|
+
for sym, secs in parse_pairs(os.environ["STOCK_SECTIONS"], "=").items()
|
|
129
|
+
}
|
|
130
|
+
if os.environ.get("MF_LIST", "").strip():
|
|
131
|
+
prefs.funds = [c for c in parse_list(os.environ["MF_LIST"]) if c.isdigit()]
|
|
132
|
+
if os.environ.get("SMART_MESSAGING", "").strip():
|
|
133
|
+
prefs.smart = os.environ["SMART_MESSAGING"].strip().lower() in ("1", "true", "yes", "on")
|
|
134
|
+
if os.environ.get("SMART_TOP_N", "").strip():
|
|
135
|
+
prefs.smart_top_n = max(1, min(20, int(os.environ["SMART_TOP_N"])))
|
|
136
|
+
if os.environ.get("SMART_AI_TOP", "").strip():
|
|
137
|
+
prefs.smart_ai_top = max(0, int(os.environ["SMART_AI_TOP"]))
|
|
138
|
+
if os.environ.get("SMART_EMAIL_FULL", "").strip():
|
|
139
|
+
prefs.smart_email_full = os.environ["SMART_EMAIL_FULL"].strip().lower() not in ("0", "false", "no", "off")
|
|
140
|
+
if os.environ.get("REPORT_BASE_URL", "").strip():
|
|
141
|
+
prefs.report_base_url = os.environ["REPORT_BASE_URL"].strip()
|
|
142
|
+
if os.environ.get("CHANNEL_LANGUAGES", "").strip():
|
|
143
|
+
prefs.channel_languages = {
|
|
144
|
+
ch.lower(): normalize_language(lang) for ch, lang in parse_pairs(os.environ["CHANNEL_LANGUAGES"], ":").items()
|
|
145
|
+
}
|
|
146
|
+
return prefs
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _run_smart(targets, prefs, config, args) -> int:
|
|
150
|
+
"""Smart messaging: top-N message + full report (HTML file here, since
|
|
151
|
+
a GitHub runner has no server to host the link)."""
|
|
152
|
+
from ..services.smart_report import run_smart
|
|
153
|
+
|
|
154
|
+
logger.info("Smart messaging: %d stocks, AI for the top %d, message shows top %d",
|
|
155
|
+
len(targets), prefs.smart_ai_top, prefs.smart_top_n)
|
|
156
|
+
dispatcher = NotificationDispatcher(config)
|
|
157
|
+
reports, delivery, rows, token, full_html = run_smart(targets, prefs, config, dispatcher=dispatcher,
|
|
158
|
+
dry_run=args.dry_run)
|
|
159
|
+
out_dir = Path(args.output_dir)
|
|
160
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
stamp = datetime.now(ZoneInfo(args.timezone)).strftime("%Y%m%d-%H%M")
|
|
162
|
+
from ..services.smart_report import html_report
|
|
163
|
+
|
|
164
|
+
(out_dir / f"marketmyna-{stamp}-full.html").write_text(
|
|
165
|
+
full_html or html_report(rows, f"MarketMyna · {len(rows)} stocks", datetime.now(ZoneInfo(args.timezone)), None),
|
|
166
|
+
encoding="utf-8")
|
|
167
|
+
for lang, (title, body) in reports.items():
|
|
168
|
+
print(f"===== {title} [{lang}] =====\n{body}\n")
|
|
169
|
+
(out_dir / f"marketmyna-{stamp}-{lang}.txt").write_text(f"{title}\n\n{body}\n", encoding="utf-8")
|
|
170
|
+
good = [r for r in rows if "error" not in r]
|
|
171
|
+
if not good:
|
|
172
|
+
logger.error("Every symbol failed - check the symbols, market and data sources above.")
|
|
173
|
+
if not args.dry_run:
|
|
174
|
+
notify_failure(config, "No stock could be analyzed.", dispatcher=dispatcher)
|
|
175
|
+
return 1
|
|
176
|
+
for name, ok in delivery.items():
|
|
177
|
+
logger.info("Notification %-12s %s", name, "sent" if ok else "FAILED")
|
|
178
|
+
if delivery and not any(delivery.values()):
|
|
179
|
+
return 2
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
184
|
+
parser = argparse.ArgumentParser(prog="python -m marketmyna.cli.scheduled_run", description=__doc__.split("\n\n")[0])
|
|
185
|
+
parser.add_argument("--symbols", default=os.environ.get("STOCK_LIST", ""), help="comma-separated; env STOCK_LIST")
|
|
186
|
+
parser.add_argument("--watchlist", help="use a saved in-app watchlist instead of --symbols")
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--market", default=os.environ.get("MARKET", "india").strip().lower() or "india", choices=sorted(MARKET_LABELS)
|
|
189
|
+
)
|
|
190
|
+
parser.add_argument(
|
|
191
|
+
"--mode", default=os.environ.get("ANALYSIS_MODE", "standard").strip().lower() or "standard", choices=MODES
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument("--timezone", default=os.environ.get("REPORT_TIMEZONE", "Asia/Kolkata") or "Asia/Kolkata")
|
|
194
|
+
parser.add_argument("--language", help="report language code (env REPORT_LANGUAGE)")
|
|
195
|
+
parser.add_argument("--force-run", action="store_true", help="run even on weekends")
|
|
196
|
+
parser.add_argument("--dry-run", action="store_true", help="print the report, don't send it")
|
|
197
|
+
parser.add_argument("--output-dir", default="reports", help="where to save the report file")
|
|
198
|
+
return parser
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> int:
|
|
202
|
+
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s")
|
|
203
|
+
args = build_parser().parse_args(argv)
|
|
204
|
+
config = Config.load()
|
|
205
|
+
if config.accounts_enabled:
|
|
206
|
+
# Accounts mode: report on the platform owner's lists, like the in-app scheduler.
|
|
207
|
+
from ..core import tenant
|
|
208
|
+
from ..services.accounts import owner_scope
|
|
209
|
+
|
|
210
|
+
tenant.enable()
|
|
211
|
+
with owner_scope(config):
|
|
212
|
+
return _main(args, config)
|
|
213
|
+
return _main(args, config)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _main(args, config) -> int:
|
|
217
|
+
|
|
218
|
+
spec = _watchlist_symbols(config, args.watchlist) if args.watchlist else args.symbols
|
|
219
|
+
targets = parse_targets(spec, args.market)
|
|
220
|
+
if not targets:
|
|
221
|
+
logger.error("No symbols to analyze. Set STOCK_LIST (e.g. RELIANCE,TCS,INFY) or pass --symbols.")
|
|
222
|
+
return 1
|
|
223
|
+
|
|
224
|
+
check_days = _env_flag("TRADING_DAY_CHECK_ENABLED", True) and not args.force_run
|
|
225
|
+
if check_days:
|
|
226
|
+
open_targets = [t for t in targets if is_trading_day(t.market)]
|
|
227
|
+
skipped = len(targets) - len(open_targets)
|
|
228
|
+
if skipped:
|
|
229
|
+
logger.info("Skipping %d symbol(s): market closed today (use --force-run to override)", skipped)
|
|
230
|
+
targets = open_targets
|
|
231
|
+
if not targets:
|
|
232
|
+
logger.info("All markets closed today - nothing to do.")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
from ..llm.channels import build_routes
|
|
236
|
+
|
|
237
|
+
routes = build_routes(config)
|
|
238
|
+
if routes:
|
|
239
|
+
logger.info("AI routes (in order): %s", ", ".join(r.label() for r in routes))
|
|
240
|
+
else:
|
|
241
|
+
logger.warning("No AI model configured (LLM_MODEL / LLM_CHANNELS): reports carry technical/risk signals but no AI decision.")
|
|
242
|
+
|
|
243
|
+
prefs = build_prefs(config, args.mode, args.timezone, args.language)
|
|
244
|
+
logger.info("Report: language=%s sections=%s", prefs.language, ",".join(prefs.sections))
|
|
245
|
+
if prefs.smart:
|
|
246
|
+
return _run_smart(targets, prefs, config, args)
|
|
247
|
+
|
|
248
|
+
results = []
|
|
249
|
+
for i, target in enumerate(targets, 1):
|
|
250
|
+
logger.info("[%d/%d] Analyzing %s (%s, %s)", i, len(targets), target.symbol, target.market, args.mode)
|
|
251
|
+
result = analyze_symbol(
|
|
252
|
+
target.symbol, target.market, args.mode, config, sections=prefs.sections_for(target.symbol)
|
|
253
|
+
)
|
|
254
|
+
if not result["ok"]:
|
|
255
|
+
logger.warning("%s: %s", target.symbol, result.get("error"))
|
|
256
|
+
results.append(result)
|
|
257
|
+
|
|
258
|
+
produced_anything = any(r.get("dashboard") for r in results)
|
|
259
|
+
if not produced_anything:
|
|
260
|
+
logger.error("Every symbol failed - check the symbols, market and data sources above.")
|
|
261
|
+
|
|
262
|
+
dispatcher = NotificationDispatcher(config)
|
|
263
|
+
reports, delivery = deliver_report(results, prefs, config, dispatcher=dispatcher, dry_run=args.dry_run)
|
|
264
|
+
|
|
265
|
+
out_dir = Path(args.output_dir)
|
|
266
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
stamp = datetime.now(ZoneInfo(args.timezone)).strftime("%Y%m%d-%H%M")
|
|
268
|
+
for lang, (title, body) in reports.items():
|
|
269
|
+
print(f"===== {title} [{lang}] =====\n{body}\n")
|
|
270
|
+
(out_dir / f"marketmyna-{stamp}-{lang}.txt").write_text(f"{title}\n\n{body}\n", encoding="utf-8")
|
|
271
|
+
|
|
272
|
+
if args.dry_run:
|
|
273
|
+
logger.info("Dry run: not sending.")
|
|
274
|
+
return 0 if produced_anything else 1
|
|
275
|
+
if not produced_anything and not args.dry_run:
|
|
276
|
+
errors = "\n".join(f"{r['symbol']}: {r.get('error', 'no data')}" for r in results)
|
|
277
|
+
notify_failure(config, f"No stock could be analyzed.\n{errors}", dispatcher=dispatcher)
|
|
278
|
+
|
|
279
|
+
if not dispatcher.configured_channels("report"):
|
|
280
|
+
logger.warning("No notification channel configured (e.g. TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID); report only printed.")
|
|
281
|
+
return 0 if produced_anything else 1
|
|
282
|
+
|
|
283
|
+
for name, ok in delivery.items():
|
|
284
|
+
lang = normalize_language(prefs.channel_languages.get(name) or prefs.language)
|
|
285
|
+
logger.info("Notification %-12s [%s] %s", name, lang, "sent" if ok else "FAILED")
|
|
286
|
+
if not any(delivery.values()):
|
|
287
|
+
return 2
|
|
288
|
+
return 0 if produced_anything else 1
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
sys.exit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""What the app may say about a single stock: facts, explanations and
|
|
2
|
+
comparisons, never a call.
|
|
3
|
+
|
|
4
|
+
MarketMyna never gives a buy / hold / sell call, price target, entry, exit
|
|
5
|
+
or stop loss. Where a direction helps, it is shown as an outlook: bullish,
|
|
6
|
+
neutral or bearish (outlook() below). The frontend reads mode() ("analytics")
|
|
7
|
+
to pick its wording.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
MODE = "analytics"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def mode() -> str:
|
|
16
|
+
return MODE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mode_for(user_id) -> str:
|
|
20
|
+
"""mode() for the /me endpoint (the frontend reads it to pick the wording)."""
|
|
21
|
+
return MODE
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# A buy / hold / sell style rating (an analyst consensus, an AI reply) as a direction.
|
|
25
|
+
OUTLOOKS = {"strong_buy": "bullish", "buy": "bullish", "hold": "neutral", "sell": "bearish", "strong_sell": "bearish"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def outlook(rating) -> "str | None":
|
|
29
|
+
"""bullish / neutral / bearish for a rating (or an outlook already),
|
|
30
|
+
None when there is none."""
|
|
31
|
+
value = str(rating or "").lower()
|
|
32
|
+
return value if value in ("bullish", "neutral", "bearish") else OUTLOOKS.get(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Appended to every AI prompt that writes about a single security, so no
|
|
36
|
+
# agent slips a call into its prose.
|
|
37
|
+
NO_CALLS_RULE = (
|
|
38
|
+
"This is an analytics tool that is not registered with SEBI as an investment adviser or research "
|
|
39
|
+
"analyst. Describe what the data shows as observations. Never recommend buying, holding, selling, "
|
|
40
|
+
"accumulating, booking profit or exiting, and never give a price target, entry, exit or stop loss. A "
|
|
41
|
+
"direction may be described only as a bullish, neutral or bearish outlook, never as an instruction. Do "
|
|
42
|
+
"not use words like attractive, cheap, expensive, undervalued or overvalued as a verdict."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def guard_prompt(system_prompt: str) -> str:
|
|
47
|
+
"""The system prompt, plus NO_CALLS_RULE."""
|
|
48
|
+
return f"{system_prompt}\n\n{NO_CALLS_RULE}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Response fields that carry a call, target or stop; always stripped.
|
|
52
|
+
CALL_FIELDS = ("decision_type", "target_price", "stop_loss", "buy_point", "sell_point", "action",
|
|
53
|
+
"ai_target", "ai_stop", "technical_signal")
|