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,491 @@
|
|
|
1
|
+
"""One scheduled-analysis run: analyze a list of symbols and turn the
|
|
2
|
+
results into a human-readable report for notification channels.
|
|
3
|
+
|
|
4
|
+
Shared by the in-app RuntimeSchedulerService and the headless runner
|
|
5
|
+
(marketmyna/cli/scheduled_run.py, for cron or a server), so a scheduled
|
|
6
|
+
message looks the same no matter which of them sent it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Optional
|
|
15
|
+
from zoneinfo import ZoneInfo
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
MARKET_LABELS = {
|
|
20
|
+
"us": "US",
|
|
21
|
+
"india": "India",
|
|
22
|
+
"china_a": "China A",
|
|
23
|
+
"hong_kong": "Hong Kong",
|
|
24
|
+
"crypto": "Crypto",
|
|
25
|
+
"global": "Global",
|
|
26
|
+
}
|
|
27
|
+
# Timezone the trading-day check and report timestamp use, per market.
|
|
28
|
+
MARKET_TIMEZONES = {
|
|
29
|
+
"india": "Asia/Kolkata",
|
|
30
|
+
"us": "America/New_York",
|
|
31
|
+
"china_a": "Asia/Shanghai",
|
|
32
|
+
"hong_kong": "Asia/Hong_Kong",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Target:
|
|
39
|
+
symbol: str
|
|
40
|
+
market: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_targets(spec: str, default_market: str) -> list[Target]:
|
|
44
|
+
""""RELIANCE, TCS, us:AAPL" -> targets; a "market:" prefix overrides
|
|
45
|
+
the default market for that one symbol. Duplicates are dropped."""
|
|
46
|
+
seen: set[tuple[str, str]] = set()
|
|
47
|
+
targets: list[Target] = []
|
|
48
|
+
for raw in spec.replace("\n", ",").split(","):
|
|
49
|
+
item = raw.strip()
|
|
50
|
+
if not item:
|
|
51
|
+
continue
|
|
52
|
+
market = default_market
|
|
53
|
+
if ":" in item:
|
|
54
|
+
prefix, rest = item.split(":", 1)
|
|
55
|
+
if prefix.lower() in MARKET_LABELS:
|
|
56
|
+
market, item = prefix.lower(), rest.strip()
|
|
57
|
+
key = (item.upper(), market)
|
|
58
|
+
if item and key not in seen:
|
|
59
|
+
seen.add(key)
|
|
60
|
+
targets.append(Target(item.upper(), market))
|
|
61
|
+
return targets
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_trading_day(market: str, now: Optional[datetime] = None) -> bool:
|
|
65
|
+
"""Weekday check in the market's own timezone. Crypto never closes.
|
|
66
|
+
Exchange holidays aren't known here - a holiday run just reports
|
|
67
|
+
the previous close."""
|
|
68
|
+
if market in ("crypto", "global"):
|
|
69
|
+
return True
|
|
70
|
+
tz = ZoneInfo(MARKET_TIMEZONES.get(market, "UTC"))
|
|
71
|
+
now = now.astimezone(tz) if now else datetime.now(tz)
|
|
72
|
+
return now.weekday() < 5
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _quote(symbol: str, market: str) -> dict:
|
|
76
|
+
"""Best-effort price/change for the report header line."""
|
|
77
|
+
try:
|
|
78
|
+
from ..core.constants import Markets
|
|
79
|
+
from ..data.china import ChinaAFetcher, HongKongFetcher
|
|
80
|
+
from ..data.crypto import CryptoDataFetcher
|
|
81
|
+
from ..data.india import IndiaDataFetcher
|
|
82
|
+
from ..data.stocks import StockDataFetcher
|
|
83
|
+
|
|
84
|
+
fetcher = {
|
|
85
|
+
Markets.INDIA: IndiaDataFetcher,
|
|
86
|
+
Markets.CRYPTO: CryptoDataFetcher,
|
|
87
|
+
Markets.CHINA_A: ChinaAFetcher,
|
|
88
|
+
Markets.HONG_KONG: HongKongFetcher,
|
|
89
|
+
}.get(Markets(market), StockDataFetcher)()
|
|
90
|
+
return fetcher.get_quote(symbol) or {}
|
|
91
|
+
except Exception as exc: # the report is still useful without a price
|
|
92
|
+
logger.warning("Quote for %s (%s) unavailable: %s", symbol, market, exc)
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Yahoo Finance ticker for markets whose native codes differ.
|
|
97
|
+
def yahoo_symbol(symbol: str, market: str) -> str:
|
|
98
|
+
s = symbol.upper()
|
|
99
|
+
if market == "india":
|
|
100
|
+
return s if s.startswith("^") or s.endswith((".NS", ".BO")) else f"{s}.NS"
|
|
101
|
+
if market == "china_a":
|
|
102
|
+
return f"{s}.SS" if s[:1] in "569" else f"{s}.BJ" if s[:1] in "48" else f"{s}.SZ"
|
|
103
|
+
if market == "hong_kong":
|
|
104
|
+
return f"{int(s):04d}.HK" if s.isdigit() else s
|
|
105
|
+
if market == "crypto":
|
|
106
|
+
return s if "-" in s else f"{s}-USD"
|
|
107
|
+
return s
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
MARKET_INDICES = {
|
|
111
|
+
"india": [("^NSEI", "NIFTY 50"), ("^BSESN", "SENSEX"), ("^NSEBANK", "NIFTY Bank")],
|
|
112
|
+
"us": [("^GSPC", "S&P 500"), ("^IXIC", "Nasdaq"), ("^DJI", "Dow Jones")],
|
|
113
|
+
"china_a": [("000001.SS", "Shanghai Composite"), ("399001.SZ", "Shenzhen Component")],
|
|
114
|
+
"hong_kong": [("^HSI", "Hang Seng")],
|
|
115
|
+
"crypto": [("BTC-USD", "Bitcoin"), ("ETH-USD", "Ethereum")],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def market_overview(markets) -> list[dict]:
|
|
120
|
+
"""Index level + day change for each market in the run (best effort)."""
|
|
121
|
+
from ..data.stocks import StockDataFetcher
|
|
122
|
+
|
|
123
|
+
fetcher = StockDataFetcher()
|
|
124
|
+
out = []
|
|
125
|
+
for market in dict.fromkeys(markets):
|
|
126
|
+
for ticker, name in MARKET_INDICES.get(market, []):
|
|
127
|
+
try:
|
|
128
|
+
q = fetcher.get_quote(ticker)
|
|
129
|
+
out.append({"market": market, "name": name, "price": q.get("price"),
|
|
130
|
+
"change_percent": q.get("change_percent")})
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.warning("Index %s unavailable: %s", ticker, exc)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _to_str_date(value):
|
|
137
|
+
if isinstance(value, (list, tuple)):
|
|
138
|
+
value = value[0] if value else None
|
|
139
|
+
return value.isoformat() if hasattr(value, "isoformat") else (str(value) if value else None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_extras(symbol: str, market: str, sections, ctx=None) -> dict:
|
|
143
|
+
"""Per-stock data beyond the agent pipeline, fetched only for the
|
|
144
|
+
sections someone actually asked for. Every piece is best effort."""
|
|
145
|
+
sections = set(sections or [])
|
|
146
|
+
extras: dict = {}
|
|
147
|
+
yf_symbol = yahoo_symbol(symbol, market)
|
|
148
|
+
info: dict = {}
|
|
149
|
+
|
|
150
|
+
def load_info() -> dict:
|
|
151
|
+
nonlocal info
|
|
152
|
+
if not info and market != "crypto":
|
|
153
|
+
try:
|
|
154
|
+
import yfinance as yf
|
|
155
|
+
|
|
156
|
+
info = yf.Ticker(yf_symbol).info or {}
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.warning("Info for %s unavailable: %s", yf_symbol, exc)
|
|
159
|
+
return info
|
|
160
|
+
|
|
161
|
+
# The movers list flags stocks near their 52-week high / low, so it needs the range too.
|
|
162
|
+
if "price" in sections or "calls" in sections:
|
|
163
|
+
data = load_info() if market != "crypto" else {}
|
|
164
|
+
extras["high_52w"] = data.get("fiftyTwoWeekHigh")
|
|
165
|
+
extras["low_52w"] = data.get("fiftyTwoWeekLow")
|
|
166
|
+
extras["name"] = data.get("shortName") or data.get("longName")
|
|
167
|
+
|
|
168
|
+
if "fundamentals" in sections and market != "crypto":
|
|
169
|
+
fundamentals = getattr(ctx, "fundamental_summary", None) if ctx else None
|
|
170
|
+
if not fundamentals and load_info():
|
|
171
|
+
try:
|
|
172
|
+
from ..analysis.fundamental import FundamentalAnalyzer, yfinance_fundamentals
|
|
173
|
+
|
|
174
|
+
fundamentals = FundamentalAnalyzer(yfinance_fundamentals(info)).get_summary()
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.warning("Fundamentals for %s unavailable: %s", symbol, exc)
|
|
177
|
+
if fundamentals:
|
|
178
|
+
extras["fundamentals"] = {
|
|
179
|
+
k: fundamentals.get(k)
|
|
180
|
+
for k in ("overall_score", "valuation", "profitability", "financial_health", "growth", "dividend")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if "analysts" in sections and load_info().get("recommendationKey"):
|
|
184
|
+
extras["analysts"] = {
|
|
185
|
+
"recommendation": info.get("recommendationKey"),
|
|
186
|
+
"target_mean": info.get("targetMeanPrice"),
|
|
187
|
+
"count": info.get("numberOfAnalystOpinions"),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if "events" in sections and market != "crypto":
|
|
191
|
+
try:
|
|
192
|
+
import yfinance as yf
|
|
193
|
+
|
|
194
|
+
cal = yf.Ticker(yf_symbol).calendar or {}
|
|
195
|
+
extras["events"] = {
|
|
196
|
+
"earnings_date": _to_str_date(cal.get("Earnings Date")),
|
|
197
|
+
"ex_dividend_date": _to_str_date(cal.get("Ex-Dividend Date")),
|
|
198
|
+
}
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
logger.warning("Calendar for %s unavailable: %s", yf_symbol, exc)
|
|
201
|
+
|
|
202
|
+
if "valuation" in sections and market != "crypto":
|
|
203
|
+
try:
|
|
204
|
+
from .valuation import meter
|
|
205
|
+
|
|
206
|
+
v = meter(symbol, market)
|
|
207
|
+
if v.get("status") == "ok":
|
|
208
|
+
extras["valuation"] = {k: v.get(k) for k in ("position", "zone", "multiples", "sector")}
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
logger.warning("Valuation for %s unavailable: %s", symbol, exc)
|
|
211
|
+
|
|
212
|
+
if "levels" in sections:
|
|
213
|
+
# Daily-bar levels (support, resistance, stop, ATR, ranges) from the
|
|
214
|
+
# history the Technical step already fetched.
|
|
215
|
+
try:
|
|
216
|
+
from .smart_report import compute_snapshot
|
|
217
|
+
|
|
218
|
+
history = getattr(ctx, "history", None) if ctx else None
|
|
219
|
+
if history is None:
|
|
220
|
+
from ..agent.agents import _resolve_fetcher
|
|
221
|
+
|
|
222
|
+
history = _resolve_fetcher(market).get_history(symbol, period="1y")
|
|
223
|
+
snap = compute_snapshot(history)
|
|
224
|
+
extras["levels"] = {k: snap.get(k) for k in (
|
|
225
|
+
"price", "buy_point", "sell_point", "stop_loss", "atr", "normal_low", "normal_high",
|
|
226
|
+
"week_low", "week_high", "trend")}
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
logger.warning("Trade levels for %s unavailable: %s", symbol, exc)
|
|
229
|
+
|
|
230
|
+
if "news" in sections:
|
|
231
|
+
# The pipeline's News step already fetched headlines; reuse them.
|
|
232
|
+
news = getattr(ctx, "news", None) if ctx else None
|
|
233
|
+
if news is None:
|
|
234
|
+
try:
|
|
235
|
+
from ..data.news_search import stock_news
|
|
236
|
+
|
|
237
|
+
news = stock_news(symbol, market, limit=3, name=extras.get("name"))
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
logger.warning("News for %s unavailable: %s", symbol, exc)
|
|
240
|
+
news = []
|
|
241
|
+
extras["news"] = news[:3]
|
|
242
|
+
return extras
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def analyze_symbol(symbol: str, market: str, mode: str, config, orchestrator=None, sections=None) -> dict:
|
|
246
|
+
"""Run the agent pipeline for one symbol, never raising.
|
|
247
|
+
|
|
248
|
+
If a critical stage (normally the AI decision) fails, the opinions
|
|
249
|
+
that did finish (technical, risk) are still returned in `dashboard`
|
|
250
|
+
with ok=False, so the report can show the technical read instead of
|
|
251
|
+
only an error. `sections` (report section ids) decides which extra
|
|
252
|
+
data - news, fundamentals, analysts, events - is fetched.
|
|
253
|
+
"""
|
|
254
|
+
from ..agent.context import AgentContext
|
|
255
|
+
from ..agent.dashboard import build_dashboard
|
|
256
|
+
from ..agent.orchestrator import AgentOrchestrator
|
|
257
|
+
|
|
258
|
+
ctx = AgentContext(symbol=symbol, market=market, mode=mode)
|
|
259
|
+
result: dict = {"symbol": symbol, "market": market, "quote": _quote(symbol, market)}
|
|
260
|
+
try:
|
|
261
|
+
orchestrator = orchestrator or AgentOrchestrator(config=config)
|
|
262
|
+
orchestrator.run(symbol, market, mode=mode, ctx=ctx)
|
|
263
|
+
result.update(ok=True, dashboard=build_dashboard(ctx))
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
result.update(
|
|
266
|
+
ok=False,
|
|
267
|
+
error=str(exc) or type(exc).__name__,
|
|
268
|
+
dashboard=build_dashboard(ctx) if ctx.opinions else None,
|
|
269
|
+
)
|
|
270
|
+
if sections:
|
|
271
|
+
extras = collect_extras(symbol, market, sections, ctx)
|
|
272
|
+
result["name"] = extras.pop("name", None)
|
|
273
|
+
result["extras"] = extras
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def notify_failure(config, summary: str, dispatcher=None) -> dict[str, bool]:
|
|
278
|
+
"""Tell the system-error channels a scheduled run failed, so a silent
|
|
279
|
+
morning (no report) never goes unnoticed. Never raises."""
|
|
280
|
+
try:
|
|
281
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
282
|
+
|
|
283
|
+
dispatcher = dispatcher or NotificationDispatcher(config)
|
|
284
|
+
return dispatcher.send_all(summary, title="⚠️ MarketMyna scheduled run failed", purpose="system_error")
|
|
285
|
+
except Exception:
|
|
286
|
+
logger.exception("Could not send failure notification")
|
|
287
|
+
return {}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def deliver_report(results: list[dict], prefs, config, dispatcher=None, dry_run: bool = False):
|
|
291
|
+
"""Render the report once per language any channel wants, then send
|
|
292
|
+
each configured channel its own language.
|
|
293
|
+
|
|
294
|
+
Returns (reports, delivery): reports maps language -> (title, body)
|
|
295
|
+
and always includes prefs.language; delivery maps channel -> sent ok.
|
|
296
|
+
"""
|
|
297
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
298
|
+
from .report import market_review, render_reports
|
|
299
|
+
from .report_i18n import normalize_language
|
|
300
|
+
|
|
301
|
+
dispatcher = dispatcher or NotificationDispatcher(config)
|
|
302
|
+
channels = dispatcher.configured_channels("report")
|
|
303
|
+
by_lang = prefs.languages_needed(channels)
|
|
304
|
+
languages = [normalize_language(prefs.language), *by_lang]
|
|
305
|
+
|
|
306
|
+
overview = market_take = None
|
|
307
|
+
if "market" in prefs.sections:
|
|
308
|
+
overview = market_overview(r["market"] for r in results)
|
|
309
|
+
market_take = market_review(overview, results, config)
|
|
310
|
+
funds = None
|
|
311
|
+
if "funds" in prefs.sections and prefs.funds:
|
|
312
|
+
from .funds import fund_snapshots
|
|
313
|
+
|
|
314
|
+
funds = fund_snapshots(prefs.funds)
|
|
315
|
+
try: # lumpsum window / dip check per fund (cached, equity and hybrid only)
|
|
316
|
+
from .mf_lumpsum import assess_fund
|
|
317
|
+
|
|
318
|
+
for f in funds:
|
|
319
|
+
f["lumpsum"] = assess_fund(f["code"], f.get("category") or "", f.get("name") or "")
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
logger.warning("Fund dip check unavailable: %s", exc)
|
|
322
|
+
|
|
323
|
+
sections = prefs.all_sections()
|
|
324
|
+
portfolios = portfolio_snapshots(prefs.portfolios, results, config) if "portfolio" in sections else None
|
|
325
|
+
alerts = alert_digest(results, config) if "alerts" in sections else None
|
|
326
|
+
hot = hot_blocks(results, sections)
|
|
327
|
+
|
|
328
|
+
reports = render_reports(results, prefs, languages, config=config, overview=overview,
|
|
329
|
+
funds=funds, market_take=market_take,
|
|
330
|
+
portfolios=portfolios, alerts=alerts, hot=hot)
|
|
331
|
+
messages = {ch: reports[lang] for lang, chs in by_lang.items() for ch in chs}
|
|
332
|
+
if dry_run or not messages:
|
|
333
|
+
return reports, {}
|
|
334
|
+
return reports, dispatcher.send_each(messages, purpose="report")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _one_user_scope() -> bool:
|
|
338
|
+
"""Personal report data (portfolios, alerts) is only read for one user:
|
|
339
|
+
in accounts mode a SYSTEM or missing scope would mix every user's rows."""
|
|
340
|
+
from ..core import tenant
|
|
341
|
+
|
|
342
|
+
return not tenant.enabled() or tenant.current_user_id() is not None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
CURRENCY = {"india": "₹", "us": "$", "china_a": "¥", "hong_kong": "HK$", "crypto": "$", "mf": "₹"}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def portfolio_snapshots(names: list[str], results: list[dict], config=None, quote=None) -> list[dict]:
|
|
349
|
+
"""Value, today's change and total gain of each portfolio (all of them
|
|
350
|
+
when `names` is empty), priced from the report's own quotes where it has
|
|
351
|
+
them. Mutual funds use their latest NAV. Best effort, never raises."""
|
|
352
|
+
from ..storage import DatabaseManager, HoldingRecord, PortfolioRecord, get_db_url
|
|
353
|
+
|
|
354
|
+
if not _one_user_scope():
|
|
355
|
+
return []
|
|
356
|
+
quote = quote or _quote
|
|
357
|
+
known = {(r["symbol"].upper(), r["market"]): r.get("quote") or {} for r in results}
|
|
358
|
+
out = []
|
|
359
|
+
try:
|
|
360
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
361
|
+
rows = []
|
|
362
|
+
for p in session.query(PortfolioRecord).order_by(PortfolioRecord.id):
|
|
363
|
+
if names and p.name not in names:
|
|
364
|
+
continue
|
|
365
|
+
holdings = [(h.symbol, (h.market or "us").lower(), h.quantity or 0, h.avg_cost or 0, h.current_price or 0)
|
|
366
|
+
for h in session.query(HoldingRecord).filter(HoldingRecord.portfolio_id == p.id)
|
|
367
|
+
if (h.quantity or 0) > 0]
|
|
368
|
+
rows.append((p.name, holdings))
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
logger.warning("Portfolios unavailable for the report: %s", exc)
|
|
371
|
+
return []
|
|
372
|
+
for name, holdings in rows:
|
|
373
|
+
if not holdings:
|
|
374
|
+
continue
|
|
375
|
+
value = cost = day = prev_value = 0.0
|
|
376
|
+
movers, markets = [], set()
|
|
377
|
+
for symbol, market, qty, avg, last in holdings:
|
|
378
|
+
markets.add(market)
|
|
379
|
+
price = change = None
|
|
380
|
+
if market == "mf":
|
|
381
|
+
try:
|
|
382
|
+
from .mf_holdings import latest
|
|
383
|
+
|
|
384
|
+
info = latest(symbol) or {}
|
|
385
|
+
price, change = info.get("nav"), None
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
else:
|
|
389
|
+
q = known.get((symbol.upper(), market))
|
|
390
|
+
if q is None:
|
|
391
|
+
try:
|
|
392
|
+
q = quote(symbol, market)
|
|
393
|
+
except Exception:
|
|
394
|
+
q = {}
|
|
395
|
+
price, change = q.get("price"), q.get("change")
|
|
396
|
+
price = price or last
|
|
397
|
+
if not price:
|
|
398
|
+
continue
|
|
399
|
+
value += qty * price
|
|
400
|
+
cost += qty * avg
|
|
401
|
+
if change is not None:
|
|
402
|
+
day += qty * change
|
|
403
|
+
prev_value += qty * (price - change)
|
|
404
|
+
movers.append({"symbol": symbol.upper(), "change": qty * change,
|
|
405
|
+
"change_pct": change / (price - change) * 100 if price - change else None})
|
|
406
|
+
else:
|
|
407
|
+
prev_value += qty * price
|
|
408
|
+
movers.sort(key=lambda m: -abs(m["change"]))
|
|
409
|
+
cur = CURRENCY.get(next(iter(markets)), "") if len(markets - {"mf"}) <= 1 else ""
|
|
410
|
+
out.append({
|
|
411
|
+
"name": name, "value": value, "currency_symbol": cur,
|
|
412
|
+
"day_change": day, "day_change_pct": day / prev_value * 100 if prev_value else None,
|
|
413
|
+
"gain": value - cost if cost else None, "gain_pct": (value - cost) / cost * 100 if cost else None,
|
|
414
|
+
"movers": [m for m in movers[:3] if m["change"]],
|
|
415
|
+
})
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def alert_digest(results: list[dict], config=None, now: Optional[datetime] = None, quote=None,
|
|
420
|
+
near_pct: float = 3.0) -> dict:
|
|
421
|
+
"""The user's price alerts for the report: what fired in the last day, and
|
|
422
|
+
active price alerts within `near_pct` of their level. Never raises."""
|
|
423
|
+
from datetime import timedelta
|
|
424
|
+
|
|
425
|
+
from ..storage import DatabaseManager, PriceAlertEventRecord, PriceAlertRecord, get_db_url
|
|
426
|
+
from ..trading.alerts import describe
|
|
427
|
+
|
|
428
|
+
if not _one_user_scope():
|
|
429
|
+
return {}
|
|
430
|
+
quote = quote or _quote
|
|
431
|
+
now = now or datetime.now()
|
|
432
|
+
known = {(r["symbol"].upper(), r["market"]): r.get("quote") or {} for r in results}
|
|
433
|
+
try:
|
|
434
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
435
|
+
fired = [{"symbol": e.symbol, "message": e.message, "at": e.created_at}
|
|
436
|
+
for e in session.query(PriceAlertEventRecord)
|
|
437
|
+
.filter(PriceAlertEventRecord.created_at >= now - timedelta(hours=24))
|
|
438
|
+
.order_by(PriceAlertEventRecord.created_at.desc())]
|
|
439
|
+
active = [(a.symbol, a.market, a.kind, a.value)
|
|
440
|
+
for a in session.query(PriceAlertRecord).filter(PriceAlertRecord.status == "active")]
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
logger.warning("Alerts unavailable for the report: %s", exc)
|
|
443
|
+
return {}
|
|
444
|
+
near = []
|
|
445
|
+
for symbol, market, kind, level in active:
|
|
446
|
+
if kind not in ("price_above", "price_below") or not level:
|
|
447
|
+
continue
|
|
448
|
+
q = known.get((symbol.upper(), market))
|
|
449
|
+
if q is None:
|
|
450
|
+
try:
|
|
451
|
+
q = quote(symbol, market)
|
|
452
|
+
except Exception:
|
|
453
|
+
continue
|
|
454
|
+
known[(symbol.upper(), market)] = q
|
|
455
|
+
price = q.get("price")
|
|
456
|
+
if not price:
|
|
457
|
+
continue
|
|
458
|
+
distance = (level - price) / price * 100
|
|
459
|
+
if abs(distance) <= near_pct:
|
|
460
|
+
near.append({"symbol": symbol, "describe": describe(kind, level), "distance_pct": distance})
|
|
461
|
+
near.sort(key=lambda a: abs(a["distance_pct"]))
|
|
462
|
+
return {"fired": fired, "near": near, "active": len(active)}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# Boards each hot section shows in a report, three rows each.
|
|
466
|
+
HOT_BOARDS = {"hot_trade": ("trader", ("momentum", "activity", "breakouts")),
|
|
467
|
+
"hot_invest": ("investor", ("quality_dip", "earnings", "funds_adding"))}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def hot_blocks(results: list[dict], sections, rows_per_board: int = 3) -> list[dict]:
|
|
471
|
+
"""Hot / trending stocks for the report's stock markets (marketmyna/services/hot_stocks.py)."""
|
|
472
|
+
from . import hot_stocks
|
|
473
|
+
from .discovery import DISCOVERY_MARKETS
|
|
474
|
+
|
|
475
|
+
markets = [m for m in dict.fromkeys(r["market"] for r in results) if m in DISCOVERY_MARKETS]
|
|
476
|
+
out = []
|
|
477
|
+
for section, (area, keys) in HOT_BOARDS.items():
|
|
478
|
+
if section not in sections or not markets:
|
|
479
|
+
continue
|
|
480
|
+
per_market = {}
|
|
481
|
+
for market in markets:
|
|
482
|
+
try:
|
|
483
|
+
data = hot_stocks.trader(market) if area == "trader" else hot_stocks.investor(market)
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
logger.warning("Hot stocks (%s, %s) unavailable: %s", area, market, exc)
|
|
486
|
+
continue
|
|
487
|
+
boards = [{**b, "rows": b["rows"][:rows_per_board]} for b in data["boards"] if b["key"] in keys]
|
|
488
|
+
per_market[market] = boards
|
|
489
|
+
out.append({"section": section, "area": area, "markets": per_market})
|
|
490
|
+
return out
|
|
491
|
+
|