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,584 @@
|
|
|
1
|
+
"""Scheduled-report content: which sections a user receives, in which
|
|
2
|
+
language, on which channel - and the builder that renders it.
|
|
3
|
+
|
|
4
|
+
Plain text with emoji (no Markdown/HTML) so Telegram, email, ntfy,
|
|
5
|
+
Slack, WeChat... all render the same message.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from typing import Iterable, Optional
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
from ..core.compliance import guard_prompt, outlook
|
|
17
|
+
from .report_i18n import has_builtin, language_name, normalize_language, t, tv
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# (id, English label, description, on by default in its area)
|
|
22
|
+
SECTIONS: list[tuple[str, str, str, bool]] = [
|
|
23
|
+
("market", "Market overview", "Index levels and day change for each market in the report", True),
|
|
24
|
+
("calls", "Movers & AI outlook", "Biggest gainers and losers, stocks near a 52-week high or low, and each stock's bullish / neutral / bearish AI outlook", True),
|
|
25
|
+
("portfolio", "Your portfolio", "Portfolio value, today's change, total gain and the holdings that moved it most", True),
|
|
26
|
+
("alerts", "Your alerts", "Price alerts that fired since the last report, and active ones close to their trigger", True),
|
|
27
|
+
("hot_trade", "Hot for trading", "Today's biggest moves on heavy trading, unusual activity and breakouts in your markets", True),
|
|
28
|
+
("hot_invest", "Trending for investors", "Quality stocks on a dip, earnings momentum and what mutual funds added", False),
|
|
29
|
+
("funds", "Mutual funds", "NAV, day change and 1-month / 1-year returns for the funds you track", True),
|
|
30
|
+
("price", "Price & range", "Price, day change and 52-week range", True),
|
|
31
|
+
("levels", "Trading range", "Normal range (20-day band), expected 1-week range and average daily move (ATR)", True),
|
|
32
|
+
("ai", "AI summary", "What changed, strengths, weaknesses and risks to watch - no buy/sell call", True),
|
|
33
|
+
("technical", "Technical readings", "Indicator values such as RSI and MACD, without a verdict", True),
|
|
34
|
+
("indicators", "Technical indicators", "RSI, MACD, moving averages, Bollinger bands (values)", False),
|
|
35
|
+
("fundamentals", "Fundamentals", "Score, P/E, ROE, financial health, growth, dividend", False),
|
|
36
|
+
("risk", "Risk", "Risk level, volatility and maximum drawdown", True),
|
|
37
|
+
("analysts", "Analyst ratings", "Broker consensus of third-party analysts, as bullish / neutral / bearish", False),
|
|
38
|
+
("events", "Upcoming events", "Next earnings date and ex-dividend date", False),
|
|
39
|
+
("news", "News headlines", "Latest headlines for each stock", False),
|
|
40
|
+
("valuation", "Valuation vs sector", "P/E, price/book and price/sales against the median of the stock's sector", False),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Which side of the app a section is for (absent = both). The Scheduler shows
|
|
44
|
+
# each area only its own sections and keeps the other area's choices as saved.
|
|
45
|
+
SECTION_AREAS: dict[str, str] = {
|
|
46
|
+
"portfolio": "investor", "hot_invest": "investor", "funds": "investor",
|
|
47
|
+
"fundamentals": "investor", "analysts": "investor", "valuation": "investor",
|
|
48
|
+
"alerts": "trader", "hot_trade": "trader", "levels": "trader", "indicators": "trader",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
SECTION_IDS = [s[0] for s in SECTIONS]
|
|
52
|
+
DEFAULT_SECTIONS = [s[0] for s in SECTIONS if s[3]]
|
|
53
|
+
# Report-wide sections built once per run, not per stock.
|
|
54
|
+
REPORT_WIDE = {"market", "calls", "portfolio", "alerts", "hot_trade", "hot_invest", "funds"}
|
|
55
|
+
|
|
56
|
+
MARKET_NAMES = {
|
|
57
|
+
"us": "US", "india": "India", "china_a": "China A", "hong_kong": "Hong Kong", "crypto": "Crypto", "global": "Global",
|
|
58
|
+
}
|
|
59
|
+
CURRENCY_SYMBOLS = {"india": "โน", "us": "$", "china_a": "ยฅ", "hong_kong": "HK$", "crypto": "$", "global": ""}
|
|
60
|
+
OUTLOOK_ICONS = {"bullish": "โฒ", "neutral": "โ", "bearish": "โผ"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clean_sections(values: Optional[Iterable[str]]) -> list[str]:
|
|
64
|
+
"""Known section ids only, in catalogue order."""
|
|
65
|
+
wanted = {v.strip().lower() for v in (values or []) if v and v.strip()}
|
|
66
|
+
return [s for s in SECTION_IDS if s in wanted]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ReportPrefs:
|
|
71
|
+
language: str = "en"
|
|
72
|
+
sections: list[str] = field(default_factory=lambda: list(DEFAULT_SECTIONS))
|
|
73
|
+
# symbol -> its own section list, overriding `sections` for that stock
|
|
74
|
+
stock_sections: dict[str, list[str]] = field(default_factory=dict)
|
|
75
|
+
# channel name -> language, overriding `language` for that channel
|
|
76
|
+
channel_languages: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
mode: str = "standard"
|
|
78
|
+
timezone: str = "Asia/Kolkata"
|
|
79
|
+
# AMFI scheme codes for the "funds" section
|
|
80
|
+
funds: list[str] = field(default_factory=list)
|
|
81
|
+
# Portfolio names for the "portfolio" section (empty = all)
|
|
82
|
+
portfolios: list[str] = field(default_factory=list)
|
|
83
|
+
# Smart messaging: short top-N message + link to the full report
|
|
84
|
+
smart: bool = False
|
|
85
|
+
smart_top_n: int = 5
|
|
86
|
+
smart_ai_top: int = 10 # AI analyses only the N most interesting stocks (0 = none)
|
|
87
|
+
smart_email_full: bool = True
|
|
88
|
+
# The report file sent with the short message: "pdf", "xlsx" or "none",
|
|
89
|
+
# built from the ticked sections, to these channels (marketmyna/services/smart_file.py).
|
|
90
|
+
smart_file: str = "pdf"
|
|
91
|
+
smart_file_channels: list[str] = field(default_factory=lambda: ["email", "telegram"])
|
|
92
|
+
report_base_url: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
def sections_for(self, symbol: str) -> list[str]:
|
|
95
|
+
own = self.stock_sections.get(symbol.upper())
|
|
96
|
+
return own if own is not None else self.sections
|
|
97
|
+
|
|
98
|
+
def all_sections(self) -> set[str]:
|
|
99
|
+
out = set(self.sections)
|
|
100
|
+
for secs in self.stock_sections.values():
|
|
101
|
+
out.update(secs)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
def languages_needed(self, channels: Iterable[str]) -> dict[str, list[str]]:
|
|
105
|
+
"""language -> channels that should receive it."""
|
|
106
|
+
by_lang: dict[str, list[str]] = {}
|
|
107
|
+
for ch in channels:
|
|
108
|
+
lang = normalize_language(self.channel_languages.get(ch) or self.language)
|
|
109
|
+
by_lang.setdefault(lang, []).append(ch)
|
|
110
|
+
return by_lang
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------ formatting
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _num(value) -> Optional[float]:
|
|
117
|
+
try:
|
|
118
|
+
v = float(value)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
return v if v == v else None # NaN -> None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fmt(value, digits: int = 2) -> Optional[str]:
|
|
125
|
+
v = _num(value)
|
|
126
|
+
if v is None:
|
|
127
|
+
return None
|
|
128
|
+
return f"{v:,.{digits}f}" if abs(v) >= 1 else f"{v:.4f}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pct(value, signed: bool = True) -> Optional[str]:
|
|
132
|
+
v = _num(value)
|
|
133
|
+
if v is None:
|
|
134
|
+
return None
|
|
135
|
+
return f"{v:+.2f}%" if signed else f"{v:.1f}%"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _one_line(text: Optional[str], limit: int = 240) -> str:
|
|
139
|
+
text = " ".join((text or "").split())
|
|
140
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "โฆ"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _future(d) -> Optional[str]:
|
|
144
|
+
"""Format a date only if it's today or later."""
|
|
145
|
+
if d is None:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
day = d if isinstance(d, date) else datetime.fromisoformat(str(d)).date()
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
return day.strftime("%d %b %Y") if day >= date.today() else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _market_lines(overview: list[dict], lang: str) -> list[str]:
|
|
155
|
+
if not overview:
|
|
156
|
+
return []
|
|
157
|
+
lines = [f"๐ {t(lang, 'market_overview')}"]
|
|
158
|
+
for idx in overview:
|
|
159
|
+
change = _pct(idx.get("change_percent"))
|
|
160
|
+
arrow = "โฒ" if (_num(idx.get("change_percent")) or 0) >= 0 else "โผ"
|
|
161
|
+
lines.append(f" {arrow} {idx['name']} {_fmt(idx.get('price')) or 'โ'}" + (f" ({change})" if change else ""))
|
|
162
|
+
return lines
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _stock_block(r: dict, sections: list[str], lang: str, multi_market: bool) -> list[str]:
|
|
166
|
+
dash = r.get("dashboard") or {}
|
|
167
|
+
quote = r.get("quote") or {}
|
|
168
|
+
extras = r.get("extras") or {}
|
|
169
|
+
cur = CURRENCY_SYMBOLS.get(r["market"], "")
|
|
170
|
+
title = f"{'๐' if r.get('ok') else 'โ ๏ธ'} {r['symbol']}"
|
|
171
|
+
if r.get("name") and r["name"].upper() != r["symbol"].upper():
|
|
172
|
+
title += f" ยท {r['name']}"
|
|
173
|
+
if dash.get("outlook"):
|
|
174
|
+
word = tv(lang, dash["outlook"])
|
|
175
|
+
title += f" โ {OUTLOOK_ICONS[dash['outlook']]} {word.capitalize() if lang == 'en' else word}"
|
|
176
|
+
if multi_market:
|
|
177
|
+
title += f" [{MARKET_NAMES.get(r['market'], r['market'])}]"
|
|
178
|
+
lines = [title]
|
|
179
|
+
|
|
180
|
+
if "price" in sections:
|
|
181
|
+
bits = []
|
|
182
|
+
price = _fmt(quote.get("price"))
|
|
183
|
+
if price:
|
|
184
|
+
change = _pct(quote.get("change_percent"))
|
|
185
|
+
bits.append(f"{t(lang, 'price')} {cur}{price}" + (f" ({change})" if change else ""))
|
|
186
|
+
if bits:
|
|
187
|
+
lines.append(" ๐ฐ " + " ยท ".join(bits))
|
|
188
|
+
lo, hi = _fmt(extras.get("low_52w")), _fmt(extras.get("high_52w"))
|
|
189
|
+
if lo and hi:
|
|
190
|
+
lines.append(f" ๐ {t(lang, 'range_52w')}: {cur}{lo} โ {cur}{hi}")
|
|
191
|
+
|
|
192
|
+
lv = extras.get("levels") or {}
|
|
193
|
+
if "levels" in sections and lv:
|
|
194
|
+
lines += _level_lines(lv, cur, lang)
|
|
195
|
+
|
|
196
|
+
if "ai" in sections:
|
|
197
|
+
if dash.get("analysis_summary"):
|
|
198
|
+
lines.append(f" ๐ค {t(lang, 'ai_summary')}: {_one_line(dash['analysis_summary'])}")
|
|
199
|
+
elif not r.get("ok"):
|
|
200
|
+
lines.append(f" ๐ค {t(lang, 'ai_unavailable')}: {_one_line(r.get('error'), 160)}")
|
|
201
|
+
|
|
202
|
+
technical = next((o for o in dash.get("opinions", []) if o.get("agent_name") == "technical"), None)
|
|
203
|
+
if "technical" in sections and technical:
|
|
204
|
+
readings = _readings(technical)
|
|
205
|
+
if readings:
|
|
206
|
+
lines.append(f" ๐ {t(lang, 'readings')}: {readings}")
|
|
207
|
+
|
|
208
|
+
if "indicators" in sections and technical:
|
|
209
|
+
indicators = (technical.get("details") or {}).get("indicators") or {}
|
|
210
|
+
bits = []
|
|
211
|
+
for name, info in indicators.items():
|
|
212
|
+
value = _fmt(info.get("value"))
|
|
213
|
+
if value:
|
|
214
|
+
bits.append(f"{name.upper()} {value}")
|
|
215
|
+
if bits:
|
|
216
|
+
lines.append(f" ๐ {t(lang, 'indicators')}: " + " ยท ".join(bits))
|
|
217
|
+
|
|
218
|
+
fund = extras.get("fundamentals")
|
|
219
|
+
if "fundamentals" in sections and fund:
|
|
220
|
+
bits = [f"{t(lang, 'score')} {fund.get('overall_score', 'โ')}/100"]
|
|
221
|
+
val = (fund.get("valuation") or {})
|
|
222
|
+
if _fmt(val.get("value")):
|
|
223
|
+
bits.append(f"{t(lang, 'pe')} {_fmt(val['value'], 1)}")
|
|
224
|
+
roe = ((fund.get("profitability") or {}).get("roe") or {}).get("value")
|
|
225
|
+
if _num(roe):
|
|
226
|
+
bits.append(f"{t(lang, 'roe')} {_pct(roe, signed=False)}")
|
|
227
|
+
health = (fund.get("financial_health") or {}).get("rating")
|
|
228
|
+
if health:
|
|
229
|
+
bits.append(f"{t(lang, 'health')} {tv(lang, health)}")
|
|
230
|
+
growth = ((fund.get("growth") or {}).get("revenue_growth") or {}).get("value")
|
|
231
|
+
if _num(growth):
|
|
232
|
+
bits.append(f"{t(lang, 'revenue_growth')} {_pct(growth)}")
|
|
233
|
+
dy = (fund.get("dividend") or {}).get("yield")
|
|
234
|
+
if _num(dy):
|
|
235
|
+
bits.append(f"{t(lang, 'dividend_yield')} {_pct(dy, signed=False)}")
|
|
236
|
+
lines.append(f" ๐ฆ {t(lang, 'fundamentals')}: " + " ยท ".join(bits))
|
|
237
|
+
|
|
238
|
+
if "risk" in sections and dash.get("risk_level"):
|
|
239
|
+
bits = [tv(lang, dash["risk_level"])]
|
|
240
|
+
risk = next((o for o in dash.get("opinions", []) if o.get("agent_name") == "risk"), None)
|
|
241
|
+
details = (risk or {}).get("details") or {}
|
|
242
|
+
if _num(details.get("volatility")) is not None:
|
|
243
|
+
bits.append(f"{t(lang, 'volatility')} {_num(details['volatility']) * 100:.0f}%")
|
|
244
|
+
if _num(details.get("max_drawdown")) is not None:
|
|
245
|
+
bits.append(f"{t(lang, 'drawdown')} {_num(details['max_drawdown']) * 100:.0f}%")
|
|
246
|
+
lines.append(f" โ๏ธ {t(lang, 'risk')}: " + " ยท ".join(bits))
|
|
247
|
+
|
|
248
|
+
analysts = extras.get("analysts")
|
|
249
|
+
if "analysts" in sections and analysts and analysts.get("recommendation"):
|
|
250
|
+
# A direction, without the brokers' price target.
|
|
251
|
+
word = outlook(analysts["recommendation"]) or analysts["recommendation"]
|
|
252
|
+
text = f" ๐ฏ {t(lang, 'consensus_src')}: {tv(lang, word)}"
|
|
253
|
+
if analysts.get("count"):
|
|
254
|
+
text += f" ({t(lang, 'n_analysts', n=analysts['count'])})"
|
|
255
|
+
lines.append(text)
|
|
256
|
+
|
|
257
|
+
events = extras.get("events") or {}
|
|
258
|
+
if "events" in sections:
|
|
259
|
+
bits = []
|
|
260
|
+
if _future(events.get("earnings_date")):
|
|
261
|
+
bits.append(f"{t(lang, 'earnings')} {_future(events['earnings_date'])}")
|
|
262
|
+
if _future(events.get("ex_dividend_date")):
|
|
263
|
+
bits.append(f"{t(lang, 'ex_dividend')} {_future(events['ex_dividend_date'])}")
|
|
264
|
+
if bits:
|
|
265
|
+
lines.append(f" ๐
{t(lang, 'upcoming')}: " + " ยท ".join(bits))
|
|
266
|
+
|
|
267
|
+
news = extras.get("news") or []
|
|
268
|
+
news_view = next((o for o in dash.get("opinions", []) if o.get("agent_name") == "news"), None)
|
|
269
|
+
if "news" in sections and news:
|
|
270
|
+
header = f" ๐ฐ {t(lang, 'news')}"
|
|
271
|
+
summary = (news_view or {}).get("summary")
|
|
272
|
+
lines.append(f"{header}: {_one_line(summary, 220)}" if summary else f"{header}:")
|
|
273
|
+
for item in news:
|
|
274
|
+
source = f" โ {item['source']}" if item.get("source") else ""
|
|
275
|
+
lines.append(f" โข {_one_line(item.get('title'), 140)}{source}")
|
|
276
|
+
|
|
277
|
+
val = extras.get("valuation") or {}
|
|
278
|
+
if "valuation" in sections and val.get("position"):
|
|
279
|
+
bits = [f"{m['label']} {_fmt(m['value'], 1)} vs {_fmt(m['sector_median'], 1)}"
|
|
280
|
+
for m in val.get("multiples", []) if m.get("ratio")]
|
|
281
|
+
zone = val.get("zone")
|
|
282
|
+
word = t(lang, f"zone_{zone}") if zone else ""
|
|
283
|
+
lines.append(f" โ๏ธ {t(lang, 'valuation_vs_sector')}: {' ยท '.join(bits)} โ {val['position']:.2f}ร ({word})")
|
|
284
|
+
return lines
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _level_lines(lv: dict, cur: str, lang: str) -> list[str]:
|
|
288
|
+
"""Trading range from daily bars (marketmyna/services/smart_report.compute_snapshot):
|
|
289
|
+
ranges only, no support / resistance / stop to act on."""
|
|
290
|
+
price = _num(lv.get("price"))
|
|
291
|
+
atr = _num(lv.get("atr"))
|
|
292
|
+
bits = []
|
|
293
|
+
lo, hi = _fmt(lv.get("normal_low")), _fmt(lv.get("normal_high"))
|
|
294
|
+
if lo and hi:
|
|
295
|
+
bits.append(f"{t(lang, 'normal_range')} {cur}{lo} โ {cur}{hi}")
|
|
296
|
+
lines = [" ๐๏ธ " + " ยท ".join(bits)] if bits else []
|
|
297
|
+
more = []
|
|
298
|
+
wl, wh = _fmt(lv.get("week_low")), _fmt(lv.get("week_high"))
|
|
299
|
+
if wl and wh:
|
|
300
|
+
more.append(f"{t(lang, 'week_range')} {cur}{wl} โ {cur}{wh}")
|
|
301
|
+
if atr and price:
|
|
302
|
+
more.append(f"ATR {cur}{_fmt(atr)} ({atr / price * 100:.1f}%)")
|
|
303
|
+
if lv.get("trend"):
|
|
304
|
+
more.append(tv(lang, lv["trend"]))
|
|
305
|
+
if more:
|
|
306
|
+
lines.append(" " + " ยท ".join(more))
|
|
307
|
+
return lines
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _portfolio_lines(portfolios: list[dict], lang: str) -> list[str]:
|
|
311
|
+
lines = []
|
|
312
|
+
for p in portfolios:
|
|
313
|
+
cur = p.get("currency_symbol", "")
|
|
314
|
+
head = f"๐ผ {p['name']}: {cur}{_fmt(p['value'], 0)}"
|
|
315
|
+
if _num(p.get("day_change")) is not None:
|
|
316
|
+
head += f" ยท {t(lang, 'today')} {'+' if p['day_change'] >= 0 else '-'}{cur}{_fmt(abs(p['day_change']), 0)}"
|
|
317
|
+
if _pct(p.get("day_change_pct")):
|
|
318
|
+
head += f" ({_pct(p['day_change_pct'])})"
|
|
319
|
+
lines.append(head)
|
|
320
|
+
if _num(p.get("gain")) is not None:
|
|
321
|
+
lines.append(f" {t(lang, 'total_gain')} {'+' if p['gain'] >= 0 else '-'}{cur}{_fmt(abs(p['gain']), 0)}"
|
|
322
|
+
+ (f" ({_pct(p.get('gain_pct'))})" if _pct(p.get("gain_pct")) else ""))
|
|
323
|
+
for m in p.get("movers", []):
|
|
324
|
+
lines.append(f" {'โฒ' if m['change'] >= 0 else 'โผ'} {m['symbol']} {_pct(m['change_pct'])} "
|
|
325
|
+
f"({'+' if m['change'] >= 0 else '-'}{cur}{_fmt(abs(m['change']), 0)})")
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _alert_lines(alerts: dict, lang: str) -> list[str]:
|
|
330
|
+
lines = [f"๐ {t(lang, 'your_alerts')}"]
|
|
331
|
+
fired = alerts.get("fired") or []
|
|
332
|
+
near = alerts.get("near") or []
|
|
333
|
+
if fired:
|
|
334
|
+
lines.append(f" {t(lang, 'alerts_fired', n=len(fired))}:")
|
|
335
|
+
for e in fired[:8]:
|
|
336
|
+
lines.append(f" โข {e['symbol']} โ {_one_line(e.get('message'), 120)}")
|
|
337
|
+
if near:
|
|
338
|
+
lines.append(f" {t(lang, 'alerts_near')}:")
|
|
339
|
+
for a in near[:8]:
|
|
340
|
+
lines.append(f" โข {a['symbol']} {a['describe']} ยท {abs(a['distance_pct']):.1f}% away")
|
|
341
|
+
if not fired and not near:
|
|
342
|
+
lines.append(f" {t(lang, 'alerts_quiet', n=alerts.get('active', 0))}")
|
|
343
|
+
return lines
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _hot_lines(hot: dict, lang: str) -> list[str]:
|
|
347
|
+
key = "hot_trade" if hot.get("area") == "trader" else "hot_invest"
|
|
348
|
+
lines = [f"๐ฅ {t(lang, key)}"]
|
|
349
|
+
for market, boards in hot.get("markets", {}).items():
|
|
350
|
+
for b in boards:
|
|
351
|
+
if not b["rows"]:
|
|
352
|
+
continue
|
|
353
|
+
head = b["title"] + (f" [{MARKET_NAMES.get(market, market)}]" if len(hot["markets"]) > 1 else "")
|
|
354
|
+
lines.append(f" {head}")
|
|
355
|
+
for row in b["rows"]:
|
|
356
|
+
lines.append(f" โข {row['symbol']} {_pct(row.get('change_percent')) or ''} โ {_one_line(row.get('why'), 110)}")
|
|
357
|
+
return lines if len(lines) > 1 else []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _readings(technical: dict) -> str:
|
|
361
|
+
"""Indicator values only (no buy/sell verdict)."""
|
|
362
|
+
indicators = (technical.get("details") or {}).get("indicators") or {}
|
|
363
|
+
return " ยท ".join(f"{name.upper()} {_fmt(info.get('value'))}" for name, info in indicators.items() if _fmt(info.get("value")))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _outlook_lines(results: list[dict], lang: str) -> list[str]:
|
|
367
|
+
"""The AI outlook of each stock, grouped (bullish / neutral / bearish)."""
|
|
368
|
+
groups: dict[str, list[str]] = {"bullish": [], "neutral": [], "bearish": []}
|
|
369
|
+
for r in results:
|
|
370
|
+
direction = (r.get("dashboard") or {}).get("outlook")
|
|
371
|
+
if direction in groups:
|
|
372
|
+
groups[direction].append(r["symbol"])
|
|
373
|
+
if not any(groups.values()):
|
|
374
|
+
return []
|
|
375
|
+
lines = ["", f"๐งญ {t(lang, 'outlook')}"]
|
|
376
|
+
for key, symbols in groups.items():
|
|
377
|
+
word = tv(lang, key)
|
|
378
|
+
lines.append(f" {OUTLOOK_ICONS[key]} {word.capitalize() if lang == 'en' else word}: {', '.join(symbols) or 'โ'}")
|
|
379
|
+
return lines
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _mover_lines(results: list[dict], lang: str) -> list[str]:
|
|
383
|
+
"""Facts about the user's own list: biggest moves and 52-week extremes."""
|
|
384
|
+
moves = [(r["symbol"], _num((r.get("quote") or {}).get("change_percent"))) for r in results]
|
|
385
|
+
moves = [m for m in moves if m[1] is not None]
|
|
386
|
+
up = sorted((m for m in moves if m[1] > 0), key=lambda m: -m[1])[:3]
|
|
387
|
+
down = sorted((m for m in moves if m[1] < 0), key=lambda m: m[1])[:3]
|
|
388
|
+
near_hi, near_lo = [], []
|
|
389
|
+
for r in results:
|
|
390
|
+
price = _num((r.get("quote") or {}).get("price"))
|
|
391
|
+
hi, lo = _num((r.get("extras") or {}).get("high_52w")), _num((r.get("extras") or {}).get("low_52w"))
|
|
392
|
+
if price and hi and price >= hi * 0.97:
|
|
393
|
+
near_hi.append(r["symbol"])
|
|
394
|
+
if price and lo and price <= lo * 1.03:
|
|
395
|
+
near_lo.append(r["symbol"])
|
|
396
|
+
fmt = lambda ms: ", ".join(f"{s} {_pct(c)}" for s, c in ms) or "โ"
|
|
397
|
+
lines = [f"๐ {t(lang, 'movers')}", f" โฒ {t(lang, 'up_most')}: {fmt(up)}", f" โผ {t(lang, 'down_most')}: {fmt(down)}"]
|
|
398
|
+
if near_hi:
|
|
399
|
+
lines.append(f" โค {t(lang, 'near_high')}: {', '.join(near_hi)}")
|
|
400
|
+
if near_lo:
|
|
401
|
+
lines.append(f" โค {t(lang, 'near_low')}: {', '.join(near_lo)}")
|
|
402
|
+
return lines
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _fund_lines(funds: list[dict], lang: str) -> list[str]:
|
|
406
|
+
lines = [f"๐ผ {t(lang, 'funds')}"]
|
|
407
|
+
for f in funds:
|
|
408
|
+
change = _pct(f.get("change_percent"))
|
|
409
|
+
bits = [f"{t(lang, 'nav')} โน{_fmt(f.get('nav'), 4 if (_num(f.get('nav')) or 0) < 100 else 2)}"
|
|
410
|
+
+ (f" ({change})" if change else "")]
|
|
411
|
+
if _pct(f.get("return_1m")):
|
|
412
|
+
bits.append(f"1M {_pct(f['return_1m'])}")
|
|
413
|
+
if _pct(f.get("return_1y")):
|
|
414
|
+
bits.append(f"1Y {_pct(f['return_1y'])}")
|
|
415
|
+
lines.append(f" โข {_one_line(f.get('name'), 90)}")
|
|
416
|
+
lines.append(f" {' ยท '.join(bits)}")
|
|
417
|
+
dip = f.get("lumpsum") or {}
|
|
418
|
+
if dip.get("status") == "ok" and dip.get("level") in ("watch", "strong"):
|
|
419
|
+
below = f"{-dip['below_high'] * 100:.1f}% below high"
|
|
420
|
+
lines.append(f" ๐ {t(lang, 'dip_check')} {dip['met']}/{dip['total']} ยท {below}")
|
|
421
|
+
return lines
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def build_report(
|
|
425
|
+
results: list[dict],
|
|
426
|
+
prefs: ReportPrefs,
|
|
427
|
+
lang: str = "en",
|
|
428
|
+
overview: Optional[list[dict]] = None,
|
|
429
|
+
now: Optional[datetime] = None,
|
|
430
|
+
funds: Optional[list[dict]] = None,
|
|
431
|
+
market_take: Optional[str] = None,
|
|
432
|
+
portfolios: Optional[list[dict]] = None,
|
|
433
|
+
alerts: Optional[dict] = None,
|
|
434
|
+
hot: Optional[list[dict]] = None,
|
|
435
|
+
) -> tuple[str, str]:
|
|
436
|
+
"""(title, body) for one language. Labels come from the built-in
|
|
437
|
+
translations; free text stays as generated (see translate_report)."""
|
|
438
|
+
lang = normalize_language(lang)
|
|
439
|
+
label_lang = lang if has_builtin(lang) else "en"
|
|
440
|
+
now = (now or datetime.now(ZoneInfo(prefs.timezone))).astimezone(ZoneInfo(prefs.timezone))
|
|
441
|
+
markets = list(dict.fromkeys(r["market"] for r in results))
|
|
442
|
+
multi = len(markets) > 1
|
|
443
|
+
|
|
444
|
+
mode_label = t(label_lang, f"mode_{prefs.mode}") if prefs.mode in ("quick", "standard", "full", "specialist") else prefs.mode
|
|
445
|
+
lines = [
|
|
446
|
+
f"๐ {' + '.join(MARKET_NAMES.get(m, m) for m in markets)} ยท {t(label_lang, 'analysis', mode=mode_label)}",
|
|
447
|
+
f"๐ {now:%d %b %Y, %H:%M} {now.tzname()}",
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
if "market" in prefs.sections and overview:
|
|
451
|
+
lines += [""] + _market_lines(overview, label_lang)
|
|
452
|
+
if market_take:
|
|
453
|
+
lines.append(f" ๐ค {_one_line(market_take, 400)}")
|
|
454
|
+
|
|
455
|
+
if "calls" in prefs.sections:
|
|
456
|
+
lines += [""] + _mover_lines(results, label_lang) + _outlook_lines(results, label_lang)
|
|
457
|
+
|
|
458
|
+
if "portfolio" in prefs.sections and portfolios:
|
|
459
|
+
lines += [""] + _portfolio_lines(portfolios, label_lang)
|
|
460
|
+
|
|
461
|
+
if "alerts" in prefs.sections and alerts:
|
|
462
|
+
lines += [""] + _alert_lines(alerts, label_lang)
|
|
463
|
+
|
|
464
|
+
for block in hot or []:
|
|
465
|
+
if block.get("section") in prefs.sections:
|
|
466
|
+
hot_lines = _hot_lines(block, label_lang)
|
|
467
|
+
if hot_lines:
|
|
468
|
+
lines += [""] + hot_lines
|
|
469
|
+
|
|
470
|
+
if "funds" in prefs.sections and funds:
|
|
471
|
+
lines += [""] + _fund_lines(funds, label_lang)
|
|
472
|
+
|
|
473
|
+
for r in results:
|
|
474
|
+
# Always at least the stock's name line, as Settings promises when no section is ticked.
|
|
475
|
+
lines += [""] + _stock_block(r, prefs.sections_for(r["symbol"]), label_lang, multi)
|
|
476
|
+
|
|
477
|
+
lines += ["", t(label_lang, "disclaimer")]
|
|
478
|
+
return t(label_lang, "title"), "\n".join(lines)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------- translation
|
|
482
|
+
|
|
483
|
+
_TRANSLATE_SYSTEM = (
|
|
484
|
+
"You translate stock-market reports for a notification message. Translate every piece of "
|
|
485
|
+
"English text into {language}. Keep unchanged: ticker symbols, company names, numbers, "
|
|
486
|
+
"percentages, currency symbols, dates, emoji and the exact line breaks and indentation. "
|
|
487
|
+
"Text already in {language} stays as is. Output only the translated report, nothing else."
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def translate_report(text: str, lang: str, config) -> Optional[str]:
|
|
492
|
+
"""Translate free text with the configured LLM; None if unavailable.
|
|
493
|
+
Sent in blocks so long reports fit the model's output budget."""
|
|
494
|
+
from ..llm.channels import llm_configured
|
|
495
|
+
|
|
496
|
+
if lang == "en" or not llm_configured(config):
|
|
497
|
+
return None
|
|
498
|
+
from ..llm.backend_factory import create_generation_backend
|
|
499
|
+
from ..llm.protocol import GenerationConfig
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
backend = create_generation_backend(config, tier="fast") # translation is a level-1 job
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
logger.warning("Translation backend unavailable: %s", exc)
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
system = _TRANSLATE_SYSTEM.format(language=language_name(lang))
|
|
508
|
+
blocks, current = [], ""
|
|
509
|
+
for para in text.split("\n\n"):
|
|
510
|
+
if current and len(current) + len(para) > 1500:
|
|
511
|
+
blocks.append(current)
|
|
512
|
+
current = para
|
|
513
|
+
else:
|
|
514
|
+
current = f"{current}\n\n{para}" if current else para
|
|
515
|
+
if current:
|
|
516
|
+
blocks.append(current)
|
|
517
|
+
|
|
518
|
+
out = []
|
|
519
|
+
for block in blocks:
|
|
520
|
+
try:
|
|
521
|
+
result = backend.generate(block, GenerationConfig(), system_prompt=system)
|
|
522
|
+
translated = (result.text or "").strip()
|
|
523
|
+
out.append(translated or block)
|
|
524
|
+
except Exception as exc:
|
|
525
|
+
logger.warning("Translation to %s failed: %s", lang, exc)
|
|
526
|
+
return None
|
|
527
|
+
return "\n\n".join(out)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def render_reports(
|
|
531
|
+
results: list[dict], prefs: ReportPrefs, languages: Iterable[str], config=None,
|
|
532
|
+
overview: Optional[list[dict]] = None, now: Optional[datetime] = None,
|
|
533
|
+
funds: Optional[list[dict]] = None, market_take: Optional[str] = None,
|
|
534
|
+
portfolios: Optional[list[dict]] = None,
|
|
535
|
+
alerts: Optional[dict] = None,
|
|
536
|
+
hot: Optional[list[dict]] = None,
|
|
537
|
+
) -> dict[str, tuple[str, str]]:
|
|
538
|
+
"""language -> (title, body), translating free text when an LLM is set."""
|
|
539
|
+
out = {}
|
|
540
|
+
for lang in dict.fromkeys(normalize_language(x) for x in languages):
|
|
541
|
+
title, body = build_report(results, prefs, lang, overview, now, funds=funds, market_take=market_take,
|
|
542
|
+
portfolios=portfolios, alerts=alerts, hot=hot)
|
|
543
|
+
if lang != "en":
|
|
544
|
+
translated = translate_report(body, lang, config) if config is not None else None
|
|
545
|
+
if translated:
|
|
546
|
+
body = translated
|
|
547
|
+
if not has_builtin(lang):
|
|
548
|
+
title = translate_report(title, lang, config) or title
|
|
549
|
+
else:
|
|
550
|
+
body += "\n" + t(lang if has_builtin(lang) else "en", "english_note")
|
|
551
|
+
out[lang] = (title, body)
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def market_review(overview: list[dict], results: list[dict], config) -> Optional[str]:
|
|
556
|
+
"""Two-sentence AI read of the day's market backdrop (English; the
|
|
557
|
+
report translation step localizes it). None without an LLM."""
|
|
558
|
+
from ..llm.channels import llm_configured
|
|
559
|
+
|
|
560
|
+
if not overview or config is None or not llm_configured(config):
|
|
561
|
+
return None
|
|
562
|
+
from ..llm.backend_factory import create_generation_backend
|
|
563
|
+
from ..llm.protocol import GenerationConfig
|
|
564
|
+
|
|
565
|
+
indices = "\n".join(
|
|
566
|
+
f"- {i['name']}: {_fmt(i.get('price')) or '?'} ({_pct(i.get('change_percent')) or '?'})" for i in overview
|
|
567
|
+
)
|
|
568
|
+
outlooks = ", ".join(
|
|
569
|
+
f"{r['symbol']}={(r.get('dashboard') or {}).get('outlook') or 'n/a'}" for r in results
|
|
570
|
+
)
|
|
571
|
+
try:
|
|
572
|
+
result = create_generation_backend(config, tier="fast").generate(
|
|
573
|
+
f"Index moves today:\n{indices}\n\nAI outlook on the watchlist: {outlooks}",
|
|
574
|
+
GenerationConfig(max_tokens=200),
|
|
575
|
+
system_prompt=guard_prompt(
|
|
576
|
+
"You are a markets editor. In at most two short sentences, describe today's market "
|
|
577
|
+
"backdrop for a retail investor and what it means for the watchlist, using only the "
|
|
578
|
+
"numbers given. No bullet points, no disclaimers, no predictions stated as fact."
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
return " ".join((result.text or "").split()) or None
|
|
582
|
+
except Exception as exc:
|
|
583
|
+
logger.warning("Market review unavailable: %s", exc)
|
|
584
|
+
return None
|