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,595 @@
|
|
|
1
|
+
"""The report file Smart messaging sends with its short message.
|
|
2
|
+
|
|
3
|
+
Not a copy of every section ticked for the message: the file always holds
|
|
4
|
+
the same core set, the figures that matter for each stock, rendered as a
|
|
5
|
+
PDF (to read) or an Excel workbook (to sort and filter):
|
|
6
|
+
|
|
7
|
+
summary tiles (stocks, bullish / neutral / bearish counts, best / worst today)
|
|
8
|
+
market index levels and the AI's read of the day
|
|
9
|
+
readings what the indicators show, bullish / neutral / bearish; never a
|
|
10
|
+
buy / sell call (marketmyna/core/compliance.py)
|
|
11
|
+
portfolio value, today's change and total gain, when the schedule has one
|
|
12
|
+
stocks price, day, reading, valuation vs sector and the AI summary
|
|
13
|
+
guide how to read the report, term by term
|
|
14
|
+
cards one per stock: ranges, trend, RSI, risk, next results, the AI
|
|
15
|
+
summary and valuation vs sector
|
|
16
|
+
|
|
17
|
+
News, alerts and mutual funds stay in the message and the
|
|
18
|
+
report link. Nothing here calls the AI except the one-paragraph market read.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import io
|
|
24
|
+
import logging
|
|
25
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from ..core.numbers import finite as _num
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
WORKERS = 6
|
|
35
|
+
# Per-stock data the file needs from collect_extras (marketmyna/services/analysis_run.py).
|
|
36
|
+
FILE_EXTRAS = {"valuation", "events"}
|
|
37
|
+
FILE_TYPES = {"pdf": "pdf", "xlsx": "xlsx"}
|
|
38
|
+
# Marks AI-reviewed stocks (plain text: the PDF font has no emoji).
|
|
39
|
+
AI_TAG = " · AI"
|
|
40
|
+
DISCLAIMER = "MarketMyna · Computed from market data; AI notes may be wrong. Not investment advice."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Table:
|
|
45
|
+
title: str
|
|
46
|
+
columns: list[str]
|
|
47
|
+
rows: list[list] = field(default_factory=list)
|
|
48
|
+
note: str = ""
|
|
49
|
+
# The columns the PDF shows when the page is too narrow for all (Excel keeps every one).
|
|
50
|
+
pdf_columns: Optional[list[str]] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Text:
|
|
55
|
+
title: str
|
|
56
|
+
lines: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Tile:
|
|
61
|
+
"""A summary figure at the top of the report."""
|
|
62
|
+
label: str
|
|
63
|
+
value: str
|
|
64
|
+
sub: str = ""
|
|
65
|
+
tone: Optional[str] = None # up | down | warn | accent
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class StockCard:
|
|
70
|
+
"""One stock's details, following the ticked sections."""
|
|
71
|
+
symbol: str
|
|
72
|
+
name: str
|
|
73
|
+
call: str
|
|
74
|
+
price: str
|
|
75
|
+
change: str
|
|
76
|
+
facts: list[tuple[str, str]] = field(default_factory=list)
|
|
77
|
+
# Groups of (label, text, tone) shown side by side: the AI's view, bull vs bear, the risk team.
|
|
78
|
+
notes: list[list[tuple[str, str, Optional[str]]]] = field(default_factory=list)
|
|
79
|
+
news: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Document:
|
|
84
|
+
title: str
|
|
85
|
+
subtitle: str
|
|
86
|
+
blocks: list = field(default_factory=list)
|
|
87
|
+
highlights: list[Tile] = field(default_factory=list)
|
|
88
|
+
cards: list[StockCard] = field(default_factory=list)
|
|
89
|
+
# Shown in a box under the summary: what this report is and is not (general users).
|
|
90
|
+
notice: str = ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------- formatting
|
|
94
|
+
|
|
95
|
+
def _fmt(value, digits: int = 2) -> str:
|
|
96
|
+
v = _num(value)
|
|
97
|
+
if v is None:
|
|
98
|
+
return ""
|
|
99
|
+
return f"{v:,.{digits}f}" if abs(v) >= 1 else f"{v:.4f}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pct(value, signed: bool = True) -> str:
|
|
103
|
+
v = _num(value)
|
|
104
|
+
if v is None:
|
|
105
|
+
return ""
|
|
106
|
+
return f"{v:+.1f}%" if signed else f"{v:.1f}%"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ratio_pct(value) -> str:
|
|
110
|
+
"""0.12 -> 12% (risk metrics and yields come as fractions)."""
|
|
111
|
+
v = _num(value)
|
|
112
|
+
return "" if v is None else f"{v * 100:.0f}%"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _word(value) -> str:
|
|
116
|
+
return str(value or "").replace("_", " ").strip().capitalize()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------- building
|
|
120
|
+
|
|
121
|
+
def _extras(rows: list[dict]) -> dict:
|
|
122
|
+
"""symbol -> collect_extras() for the file's per-stock data (parallel)."""
|
|
123
|
+
from .analysis_run import collect_extras
|
|
124
|
+
|
|
125
|
+
good = [r for r in rows if "error" not in r]
|
|
126
|
+
|
|
127
|
+
def one(r):
|
|
128
|
+
try:
|
|
129
|
+
return r["symbol"], collect_extras(r["symbol"], r["market"], FILE_EXTRAS)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.warning("Extras for %s unavailable: %s", r["symbol"], exc)
|
|
132
|
+
return r["symbol"], {}
|
|
133
|
+
|
|
134
|
+
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
|
135
|
+
return dict(pool.map(one, good))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _results_like(rows: list[dict]) -> list[dict]:
|
|
139
|
+
"""Smart rows in the shape the normal report helpers take."""
|
|
140
|
+
return [{"symbol": r["symbol"], "market": r["market"],
|
|
141
|
+
"quote": {"price": r.get("price"), "change_percent": r.get("change_percent")},
|
|
142
|
+
"dashboard": {}} for r in rows if "error" not in r]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _call(r: dict) -> str:
|
|
146
|
+
"""The indicators' reading (bullish / neutral / bearish), never a call."""
|
|
147
|
+
from .smart_report import reading_label
|
|
148
|
+
|
|
149
|
+
return reading_label(r) + (AI_TAG if r.get("ai") else "")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _why(r: dict) -> str:
|
|
153
|
+
return r.get("ai_summary") or ", ".join(r.get("reasons") or [])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _valuation(r: dict, ex: dict) -> str:
|
|
157
|
+
"""Valuation against the sector median, as a comparison: an
|
|
158
|
+
"undervalued / overvalued" verdict would be a call."""
|
|
159
|
+
val = ex.get("valuation") or {}
|
|
160
|
+
position = _num(val.get("position"))
|
|
161
|
+
sector = val.get("sector") or "sector"
|
|
162
|
+
if position:
|
|
163
|
+
gap = (position - 1) * 100
|
|
164
|
+
where = (f"in line with the {sector} median" if abs(gap) < 1 else
|
|
165
|
+
f"{abs(gap):.0f}% {'below' if gap < 0 else 'above'} the {sector} median")
|
|
166
|
+
return f"Priced {where}"
|
|
167
|
+
pe = _num(r.get("pe"))
|
|
168
|
+
return f"P/E {pe:.1f}; no sector median to compare" if pe else ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _stock_table(rows: list[dict], extras: dict) -> Table:
|
|
172
|
+
from .report import CURRENCY_SYMBOLS, MARKET_NAMES
|
|
173
|
+
|
|
174
|
+
multi = len({r["market"] for r in rows}) > 1
|
|
175
|
+
cols = (["Stock", "Name"] + (["Market"] if multi else []) + ["Indicators", "Price", "Day",
|
|
176
|
+
"Valuation vs sector", "What stands out"])
|
|
177
|
+
table = Table("Stocks at a glance", cols, note=(
|
|
178
|
+
"Indicators: how trend, momentum, the price's place in its range and valuation lean today. Marked AI: "
|
|
179
|
+
"an AI summary of the figures; otherwise what the data flags. A description, not a recommendation."))
|
|
180
|
+
for r in rows:
|
|
181
|
+
if "error" in r:
|
|
182
|
+
table.rows.append([r["symbol"], f"no data: {str(r['error'])[:60]}"] + [""] * (len(cols) - 2))
|
|
183
|
+
continue
|
|
184
|
+
cur = CURRENCY_SYMBOLS.get(r["market"], "")
|
|
185
|
+
table.rows.append([r["symbol"], r.get("name") or ""] + ([MARKET_NAMES.get(r["market"], r["market"])] if multi else [])
|
|
186
|
+
+ [_call(r), f"{cur}{_fmt(r.get('price'))}", _pct(r.get("change_percent")),
|
|
187
|
+
_valuation(r, extras.get(r["symbol"]) or {}), _why(r)])
|
|
188
|
+
return table
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _band_position(r: dict) -> str:
|
|
192
|
+
"""Where the price sits in its normal (20-day) range: 0% bottom, 100% top."""
|
|
193
|
+
low, high, price = _num(r.get("normal_low")), _num(r.get("normal_high")), _num(r.get("price"))
|
|
194
|
+
if None in (low, high, price) or high <= low:
|
|
195
|
+
return ""
|
|
196
|
+
pos = (price - low) / (high - low) * 100
|
|
197
|
+
return f"{pos:.0f}%" + (" (above it)" if pos > 100 else " (below it)" if pos < 0 else "")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _rsi_word(rsi) -> str:
|
|
201
|
+
v = _num(rsi)
|
|
202
|
+
if v is None:
|
|
203
|
+
return ""
|
|
204
|
+
return f"{v:.0f}" + (" (fast rise)" if v >= 70 else " (fast fall)" if v <= 30 else "")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _range_position(r: dict) -> str:
|
|
208
|
+
"""Where today's price sits between the 52-week low (0%) and high (100%)."""
|
|
209
|
+
low, high, price = _num(r.get("low_52w")), _num(r.get("high_52w")), _num(r.get("price"))
|
|
210
|
+
if None in (low, high, price) or high <= low:
|
|
211
|
+
return ""
|
|
212
|
+
return f"{(price - low) / (high - low) * 100:.0f}% of range"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _card(r: dict, ex: dict) -> StockCard:
|
|
216
|
+
"""The important figures for one stock, the same for every report."""
|
|
217
|
+
from .report import CURRENCY_SYMBOLS
|
|
218
|
+
|
|
219
|
+
cur = CURRENCY_SYMBOLS.get(r["market"], "")
|
|
220
|
+
facts: list[tuple[str, str]] = []
|
|
221
|
+
|
|
222
|
+
def add(label: str, value) -> None:
|
|
223
|
+
if value not in (None, "", "–", " ()"):
|
|
224
|
+
facts.append((label, str(value)))
|
|
225
|
+
|
|
226
|
+
add("Normal range", f"{_fmt(r.get('normal_low'))}–{_fmt(r.get('normal_high'))}")
|
|
227
|
+
add("Position in normal range", _band_position(r))
|
|
228
|
+
add("Expected 1-week range", f"{_fmt(r.get('week_low'))}–{_fmt(r.get('week_high'))}")
|
|
229
|
+
add("52-week low", _fmt(r.get("low_52w")))
|
|
230
|
+
add("52-week high", _fmt(r.get("high_52w")))
|
|
231
|
+
add("In 52-week range", _range_position(r))
|
|
232
|
+
add("Trend", _word(r.get("trend")))
|
|
233
|
+
add("RSI (14)", _rsi_word(r.get("rsi")))
|
|
234
|
+
add("P/E", _fmt(r.get("pe"), 1))
|
|
235
|
+
add("Volatility (yearly)", _ratio_pct(r.get("volatility")))
|
|
236
|
+
add("Max drawdown", _ratio_pct(r.get("max_drawdown")))
|
|
237
|
+
add("Average daily move (ATR)", _fmt(r.get("atr")))
|
|
238
|
+
ev = ex.get("events") or {}
|
|
239
|
+
add("Next results", str(ev.get("earnings_date") or "")[:10])
|
|
240
|
+
add("Ex-dividend", str(ev.get("ex_dividend_date") or "")[:10])
|
|
241
|
+
|
|
242
|
+
summary = []
|
|
243
|
+
if _why(r):
|
|
244
|
+
label = "AI summary of the figures" if r.get("ai") else "What stands out"
|
|
245
|
+
summary.append((label, _why(r), "accent"))
|
|
246
|
+
if _valuation(r, ex):
|
|
247
|
+
summary.append(("Valuation vs sector", _valuation(r, ex), "accent"))
|
|
248
|
+
notes: list = [summary] if summary else []
|
|
249
|
+
return StockCard(symbol=r["symbol"], name=r.get("name") or "", call=_call(r),
|
|
250
|
+
price=f"{cur}{_fmt(r.get('price'))}", change=_pct(r.get("change_percent")),
|
|
251
|
+
facts=facts, notes=notes)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _highlights(rows: list[dict], good: list[dict]) -> list[Tile]:
|
|
255
|
+
ai = sum(1 for r in good if r.get("ai"))
|
|
256
|
+
tiles = [Tile("Stocks", str(len(rows)), f"{ai} reviewed by the AI" if ai else "computed readings", "accent")]
|
|
257
|
+
if good:
|
|
258
|
+
counts = {k: sum(1 for r in good if r.get("reading") == k) for k in ("positive", "mixed", "negative")}
|
|
259
|
+
tiles += [Tile("Bullish", str(counts["positive"]), "indicators lean up", "up"),
|
|
260
|
+
Tile("Neutral", str(counts["mixed"]), "indicators split", "warn"),
|
|
261
|
+
Tile("Bearish", str(counts["negative"]), "indicators lean down", "down")]
|
|
262
|
+
moves = [r for r in good if _num(r.get("change_percent")) is not None]
|
|
263
|
+
if moves:
|
|
264
|
+
best = max(moves, key=lambda r: r["change_percent"])
|
|
265
|
+
worst = min(moves, key=lambda r: r["change_percent"])
|
|
266
|
+
tiles.append(Tile("Best today", _pct(best["change_percent"]), best["symbol"], "up" if best["change_percent"] >= 0 else "down"))
|
|
267
|
+
tiles.append(Tile("Worst today", _pct(worst["change_percent"]), worst["symbol"], "down" if worst["change_percent"] < 0 else "up"))
|
|
268
|
+
return tiles
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _guide() -> Table:
|
|
272
|
+
""""How to read this report": every term the file uses, in plain words."""
|
|
273
|
+
terms = [
|
|
274
|
+
("Indicators", "▲ Bullish, ◆ Neutral or ▼ Bearish: how most of trend, momentum (RSI), "
|
|
275
|
+
"the price's place in its normal range and valuation lean today. It describes the data "
|
|
276
|
+
"and is not a recommendation to buy, sell or hold."),
|
|
277
|
+
("Valuation vs sector", "How the stock's P/E, P/B and P/S compare with the median of its sector among "
|
|
278
|
+
"the market's largest companies. A lower or higher price than peers can have "
|
|
279
|
+
"good reasons; it is a comparison, not a verdict."),
|
|
280
|
+
]
|
|
281
|
+
terms += [
|
|
282
|
+
("Normal range", "Where the price usually trades: the 20-day Bollinger band. Outside it means an unusually "
|
|
283
|
+
"large move for recent weeks."),
|
|
284
|
+
("1-week range", "Where about 2 of 3 weeks end, from recent volatility. Not a forecast or a target."),
|
|
285
|
+
("52-week range", "The lowest and highest price of the past year; 0% of range = at the low, 100% = at the high."),
|
|
286
|
+
("Trend", "Uptrend: price above its 50- and 200-day averages; downtrend: below both; sideways: neither."),
|
|
287
|
+
("RSI", "Momentum from 0 to 100. Above 70 the price rose fast recently, below 30 it fell fast."),
|
|
288
|
+
("Volatility", "How much the price swings in a year; 25% means a typical yearly move of about a quarter of "
|
|
289
|
+
"the price."),
|
|
290
|
+
("Max drawdown", "The deepest fall from a high during the past year."),
|
|
291
|
+
("AI summary of the figures", "Written by an AI model from the figures above. It can be wrong."),
|
|
292
|
+
]
|
|
293
|
+
return Table("How to read this report", ["Term", "What it means"], [list(t) for t in terms])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def build_document(rows: list[dict], prefs, config, now: datetime, title: str) -> Document:
|
|
297
|
+
"""The report file: the same core set every time (see the module doc)."""
|
|
298
|
+
from .smart_report import READING_LABELS, READING_NOTE, reading_label
|
|
299
|
+
|
|
300
|
+
good = [r for r in rows if "error" not in r]
|
|
301
|
+
ai_count = sum(1 for r in good if r.get("ai"))
|
|
302
|
+
doc = Document(title, f"{now:%d %b %Y, %H:%M %Z}".strip() + f" · {len(rows)} stocks"
|
|
303
|
+
+ (f" · {ai_count} reviewed by the AI" if ai_count else ""),
|
|
304
|
+
notice=READING_NOTE)
|
|
305
|
+
results = _results_like(rows)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
from .analysis_run import market_overview
|
|
309
|
+
from .report import market_review
|
|
310
|
+
|
|
311
|
+
overview = market_overview(r["market"] for r in good)
|
|
312
|
+
if overview:
|
|
313
|
+
take = market_review(overview, results, config)
|
|
314
|
+
doc.blocks.append(Table("Market overview", ["Index", "Level", "Day"],
|
|
315
|
+
[[i["name"], _fmt(i.get("price")), _pct(i.get("change_percent"))]
|
|
316
|
+
for i in overview], note=take or ""))
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
logger.warning("Market overview unavailable for the report file: %s", exc)
|
|
319
|
+
|
|
320
|
+
if good:
|
|
321
|
+
groups = {k: [r["symbol"] + (AI_TAG if r.get("ai") else "") for r in good if r.get("reading") == k]
|
|
322
|
+
for k in READING_LABELS}
|
|
323
|
+
doc.blocks.append(Table("What the indicators show", ["Indicators", "Stocks", "Count"],
|
|
324
|
+
[[reading_label({"reading": k}), ", ".join(v) or "—", len(v)] for k, v in groups.items()],
|
|
325
|
+
note="Grouped by how most indicators (trend, momentum, place in the range, valuation) "
|
|
326
|
+
"lean today. This sorts the data; it does not say what to do."))
|
|
327
|
+
|
|
328
|
+
if getattr(prefs, "portfolios", None):
|
|
329
|
+
try:
|
|
330
|
+
from .analysis_run import portfolio_snapshots
|
|
331
|
+
|
|
332
|
+
ports = portfolio_snapshots(prefs.portfolios, results, config)
|
|
333
|
+
if ports:
|
|
334
|
+
doc.blocks.append(Table("Your portfolio", ["Portfolio", "Value", "Today", "Today %", "Total gain", "Gain %", "Moved most"], [
|
|
335
|
+
[p["name"], f"{p.get('currency_symbol', '')}{_fmt(p.get('value'), 0)}", _fmt(p.get("day_change"), 0),
|
|
336
|
+
_pct(p.get("day_change_pct")), _fmt(p.get("gain"), 0), _pct(p.get("gain_pct")),
|
|
337
|
+
", ".join(f"{m['symbol']} {_pct(m.get('change_pct'))}" for m in p.get("movers", []))]
|
|
338
|
+
for p in ports]))
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
logger.warning("Portfolio unavailable for the report file: %s", exc)
|
|
341
|
+
|
|
342
|
+
extras = _extras(rows)
|
|
343
|
+
doc.blocks.append(_stock_table(rows, extras))
|
|
344
|
+
|
|
345
|
+
if good:
|
|
346
|
+
doc.blocks.append(_guide())
|
|
347
|
+
doc.cards = [_card(r, extras.get(r["symbol"]) or {}) for r in good]
|
|
348
|
+
doc.highlights = _highlights(rows, good)
|
|
349
|
+
return doc
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------- rendering
|
|
353
|
+
|
|
354
|
+
def to_pdf(doc: Document) -> bytes:
|
|
355
|
+
"""A4 landscape: title band, summary tiles, the sections, a card per stock."""
|
|
356
|
+
from .smart_pdf import render
|
|
357
|
+
|
|
358
|
+
return render(doc, DISCLAIMER)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _cell_value(value):
|
|
362
|
+
"""'₹1,312.40' -> 1312.4, '+1.2%' -> (0.012, percent format), so Excel
|
|
363
|
+
sorts and filters numbers as numbers; anything else stays as it is."""
|
|
364
|
+
from .report import CURRENCY_SYMBOLS
|
|
365
|
+
|
|
366
|
+
if not isinstance(value, str) or not any(ch.isdigit() for ch in value): # also keeps "INF", "NAN" as text
|
|
367
|
+
return value, None
|
|
368
|
+
text = value.replace(",", "")
|
|
369
|
+
for sign in sorted(filter(None, CURRENCY_SYMBOLS.values()), key=len, reverse=True):
|
|
370
|
+
text = text.removeprefix(sign)
|
|
371
|
+
percent = text.endswith("%")
|
|
372
|
+
try:
|
|
373
|
+
number = float(text.rstrip("%"))
|
|
374
|
+
except ValueError:
|
|
375
|
+
return value, None
|
|
376
|
+
if percent:
|
|
377
|
+
return number / 100, "0.0%" if "." in text else "0%"
|
|
378
|
+
return number, "#,##0.00" if "." in text else "#,##0"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def to_xlsx(doc: Document) -> bytes:
|
|
382
|
+
"""A styled workbook: a Summary sheet (title, summary figures, the
|
|
383
|
+
report-wide tables), "Stocks" to sort and filter, "Stock details" with
|
|
384
|
+
every ticked detail and the AI's notes per stock, and a sheet for any
|
|
385
|
+
other long table. Numbers are stored as numbers; gains are green,
|
|
386
|
+
losses red, calls coloured."""
|
|
387
|
+
from openpyxl import Workbook
|
|
388
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
389
|
+
from openpyxl.utils import get_column_letter
|
|
390
|
+
|
|
391
|
+
from .smart_pdf import tone_of
|
|
392
|
+
|
|
393
|
+
fill = lambda hex_: PatternFill("solid", fgColor=hex_) # noqa: E731
|
|
394
|
+
tone_font = {"up": "0B7A52", "down": "C02F2F", "warn": "9A6408", "accent": "1F3A8A"}
|
|
395
|
+
tone_fill = {"up": "E3F4EC", "down": "FBE7E7", "warn": "FDF3DC", "accent": "E8EDF8"}
|
|
396
|
+
thin = Side(style="thin", color="DCE0E8")
|
|
397
|
+
grid = Border(bottom=thin, top=thin, left=thin, right=thin)
|
|
398
|
+
head_font, head_fill = Font(bold=True, color="FFFFFF", size=10), fill("0F2A5C")
|
|
399
|
+
top_left = Alignment(vertical="top", wrap_text=True)
|
|
400
|
+
signed_pct = '[Color10]+0.0%;[Red]-0.0%;0.0%'
|
|
401
|
+
|
|
402
|
+
wb = Workbook()
|
|
403
|
+
|
|
404
|
+
def banner(ws, width: int) -> None:
|
|
405
|
+
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=width)
|
|
406
|
+
ws.cell(1, 1, doc.title).font = Font(bold=True, size=16, color="FFFFFF")
|
|
407
|
+
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=width)
|
|
408
|
+
ws.cell(2, 1, doc.subtitle).font = Font(italic=True, size=9, color="D6DEEE")
|
|
409
|
+
for row in (1, 2):
|
|
410
|
+
for col in range(1, width + 1):
|
|
411
|
+
ws.cell(row, col).fill = head_fill
|
|
412
|
+
ws.row_dimensions[1].height = 30
|
|
413
|
+
ws.cell(1, 1).alignment = Alignment(vertical="center", indent=1)
|
|
414
|
+
ws.cell(2, 1).alignment = Alignment(vertical="center", indent=1)
|
|
415
|
+
|
|
416
|
+
def style_value(cell, value, column: str) -> None:
|
|
417
|
+
number, number_format = _cell_value(value)
|
|
418
|
+
cell.value = number
|
|
419
|
+
cell.border = grid
|
|
420
|
+
cell.alignment = Alignment(vertical="top", wrap_text=isinstance(number, str) and len(number) > 40,
|
|
421
|
+
horizontal="right" if number_format else None)
|
|
422
|
+
tone = tone_of(value) if isinstance(value, str) else None
|
|
423
|
+
if number_format:
|
|
424
|
+
signed = isinstance(value, str) and value[:1] in "+-−" and number_format.endswith("%")
|
|
425
|
+
cell.number_format = signed_pct if signed else number_format
|
|
426
|
+
elif tone and column in ("Call", "Result", "Indicators"):
|
|
427
|
+
cell.font = Font(bold=True, color=tone_font[tone])
|
|
428
|
+
cell.fill = fill(tone_fill[tone])
|
|
429
|
+
cell.alignment = Alignment(horizontal="center", vertical="top")
|
|
430
|
+
elif tone in ("up", "down"):
|
|
431
|
+
cell.font = Font(color=tone_font[tone])
|
|
432
|
+
|
|
433
|
+
def put_table(ws, table: Table, row: int, zebra: bool = True) -> int:
|
|
434
|
+
for j, name in enumerate(table.columns, start=1):
|
|
435
|
+
c = ws.cell(row, j, name)
|
|
436
|
+
c.font, c.fill, c.border = head_font, head_fill, grid
|
|
437
|
+
c.alignment = Alignment(vertical="center", wrap_text=True)
|
|
438
|
+
ws.row_dimensions[row].height = 20
|
|
439
|
+
for i, values in enumerate(table.rows, start=1):
|
|
440
|
+
for j, (name, value) in enumerate(zip(table.columns, values), start=1):
|
|
441
|
+
c = ws.cell(row + i, j)
|
|
442
|
+
style_value(c, value, name)
|
|
443
|
+
if j == 1:
|
|
444
|
+
c.font = Font(bold=True, color="0F2A5C")
|
|
445
|
+
if zebra and i % 2 == 0 and c.fill.fgColor.rgb in (None, "00000000"):
|
|
446
|
+
c.fill = fill("F5F7FA")
|
|
447
|
+
row += len(table.rows) + 1
|
|
448
|
+
if table.note:
|
|
449
|
+
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=max(len(table.columns), 1))
|
|
450
|
+
c = ws.cell(row, 1, table.note)
|
|
451
|
+
c.font, c.alignment = Font(italic=True, size=8, color="6B7280"), Alignment(wrap_text=True, vertical="top")
|
|
452
|
+
ws.row_dimensions[row].height = 14 * (1 + len(table.note) // 140)
|
|
453
|
+
row += 1
|
|
454
|
+
return row
|
|
455
|
+
|
|
456
|
+
def section_title(ws, row: int, title: str, width: int) -> int:
|
|
457
|
+
c = ws.cell(row, 1, title)
|
|
458
|
+
c.font = Font(bold=True, size=12, color="0F2A5C")
|
|
459
|
+
for col in range(1, max(width, 1) + 1):
|
|
460
|
+
ws.cell(row, col).border = Border(bottom=Side(style="medium", color="1F3A8A"))
|
|
461
|
+
return row + 1
|
|
462
|
+
|
|
463
|
+
def fit(ws, first_row: int, cap: int = 60, wide: Optional[set] = None) -> None:
|
|
464
|
+
for i, col in enumerate(ws.iter_cols(min_row=first_row, max_row=min(ws.max_row, 400)), start=1):
|
|
465
|
+
longest = max((len(str(c.value)) for c in col if c.value is not None and not
|
|
466
|
+
(isinstance(c.value, str) and len(c.value) > 140)), default=8)
|
|
467
|
+
width = 55 if wide and i in wide else min(max(longest + 3, 9), cap)
|
|
468
|
+
ws.column_dimensions[get_column_letter(i)].width = width
|
|
469
|
+
|
|
470
|
+
def page_setup(ws) -> None:
|
|
471
|
+
ws.page_setup.orientation = "landscape"
|
|
472
|
+
ws.page_setup.fitToWidth, ws.page_setup.fitToHeight = 1, 0
|
|
473
|
+
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
|
474
|
+
ws.sheet_view.showGridLines = False
|
|
475
|
+
|
|
476
|
+
# ---- Summary
|
|
477
|
+
summary = wb.active
|
|
478
|
+
summary.title = "Summary"
|
|
479
|
+
small = [b for b in doc.blocks if not (isinstance(b, Table) and (b.title.startswith("Stocks") or len(b.rows) > 8))]
|
|
480
|
+
width = max([len(doc.highlights)] + [len(b.columns) for b in small if isinstance(b, Table)] + [6])
|
|
481
|
+
banner(summary, width)
|
|
482
|
+
row = 4
|
|
483
|
+
if doc.notice:
|
|
484
|
+
summary.merge_cells(start_row=row, start_column=1, end_row=row, end_column=width)
|
|
485
|
+
c = summary.cell(row, 1, doc.notice)
|
|
486
|
+
c.font = Font(bold=True, size=10, color="0F2A5C")
|
|
487
|
+
c.fill = fill("EEF3FB")
|
|
488
|
+
c.alignment = Alignment(wrap_text=True, vertical="center", indent=1)
|
|
489
|
+
c.border = Border(left=Side(style="thick", color="1F3A8A"))
|
|
490
|
+
summary.row_dimensions[row].height = 48
|
|
491
|
+
row += 2
|
|
492
|
+
if doc.highlights:
|
|
493
|
+
for j, t in enumerate(doc.highlights, start=1):
|
|
494
|
+
color = tone_font.get(t.tone, "0F2A5C")
|
|
495
|
+
for k, (value, font) in enumerate(((t.label.upper(), Font(bold=True, size=8, color="6B7280")),
|
|
496
|
+
(t.value, Font(bold=True, size=18, color=color)),
|
|
497
|
+
(t.sub, Font(size=8, color="6B7280")))):
|
|
498
|
+
c = summary.cell(row + k, j, value)
|
|
499
|
+
c.font, c.fill = font, fill("F7F9FC")
|
|
500
|
+
c.alignment = Alignment(horizontal="left", vertical="center", indent=1)
|
|
501
|
+
c.border = Border(left=Side(style="thick", color=color), top=thin if k == 0 else None,
|
|
502
|
+
bottom=thin if k == 2 else None, right=thin)
|
|
503
|
+
summary.row_dimensions[row + 1].height = 28
|
|
504
|
+
row += 4
|
|
505
|
+
for b in small:
|
|
506
|
+
row = section_title(summary, row, b.title, len(b.columns) if isinstance(b, Table) else width)
|
|
507
|
+
if isinstance(b, Table):
|
|
508
|
+
row = put_table(summary, b, row)
|
|
509
|
+
else:
|
|
510
|
+
for line in b.lines:
|
|
511
|
+
summary.cell(row, 1, line).alignment = Alignment(wrap_text=True)
|
|
512
|
+
row += 1
|
|
513
|
+
row += 1
|
|
514
|
+
c = summary.cell(row, 1, DISCLAIMER)
|
|
515
|
+
c.font = Font(italic=True, size=8, color="6B7280")
|
|
516
|
+
fit(summary, 4, cap=48)
|
|
517
|
+
page_setup(summary)
|
|
518
|
+
|
|
519
|
+
used = {"Summary"}
|
|
520
|
+
|
|
521
|
+
def sheet(title: str):
|
|
522
|
+
name = title[:31].replace("/", "-")
|
|
523
|
+
while name in used:
|
|
524
|
+
name = name[:28] + "_2"
|
|
525
|
+
used.add(name)
|
|
526
|
+
ws = wb.create_sheet(name)
|
|
527
|
+
page_setup(ws)
|
|
528
|
+
return ws
|
|
529
|
+
|
|
530
|
+
# ---- big tables (the stocks at a glance or a long track record)
|
|
531
|
+
for b in doc.blocks:
|
|
532
|
+
if b in small:
|
|
533
|
+
continue
|
|
534
|
+
ws = sheet("Stocks" if b.title.startswith("Stocks") else b.title)
|
|
535
|
+
end = put_table(ws, b, 1)
|
|
536
|
+
ws.freeze_panes = "B2"
|
|
537
|
+
if b.rows:
|
|
538
|
+
ws.auto_filter.ref = f"A1:{get_column_letter(len(b.columns))}{len(b.rows) + 1}"
|
|
539
|
+
fit(ws, 1)
|
|
540
|
+
if b.note and end:
|
|
541
|
+
ws.row_dimensions[end - 1].height = 28
|
|
542
|
+
|
|
543
|
+
# ---- Stock details: one row per stock with every ticked detail and the AI's notes
|
|
544
|
+
if doc.cards:
|
|
545
|
+
labels: list[str] = []
|
|
546
|
+
for card in doc.cards:
|
|
547
|
+
labels += [label for label, _ in card.facts if label not in labels]
|
|
548
|
+
# The AI's reasoning and the computed reasons share one column.
|
|
549
|
+
same = {"Why (computed)": "AI view"}
|
|
550
|
+
note_labels: list[str] = []
|
|
551
|
+
for card in doc.cards:
|
|
552
|
+
for group in card.notes:
|
|
553
|
+
note_labels += [same.get(label, label) for label, _, _ in group
|
|
554
|
+
if same.get(label, label) not in note_labels]
|
|
555
|
+
news = any(card.news for card in doc.cards)
|
|
556
|
+
columns = ["Stock", "Name", "Call", "Price", "Day"] + labels + note_labels + (["News"] if news else [])
|
|
557
|
+
rows = []
|
|
558
|
+
for card in doc.cards:
|
|
559
|
+
facts = dict(card.facts)
|
|
560
|
+
notes = {same.get(label, label): text for group in card.notes for label, text, _ in group}
|
|
561
|
+
rows.append([card.symbol, card.name, card.call, card.price, card.change]
|
|
562
|
+
+ [facts.get(label, "") for label in labels]
|
|
563
|
+
+ [notes.get(label, "") for label in note_labels]
|
|
564
|
+
+ (["\n".join(card.news)] if news else []))
|
|
565
|
+
ws = sheet("Stock details")
|
|
566
|
+
put_table(ws, Table("Stock details", columns, rows), 1)
|
|
567
|
+
ws.freeze_panes = "B2"
|
|
568
|
+
ws.auto_filter.ref = f"A1:{get_column_letter(len(columns))}{len(rows) + 1}"
|
|
569
|
+
long_cols = {i for i, name in enumerate(columns, start=1) if name in note_labels or name == "News"}
|
|
570
|
+
fit(ws, 1, wide=long_cols)
|
|
571
|
+
for i in range(2, len(rows) + 2):
|
|
572
|
+
for j in long_cols:
|
|
573
|
+
ws.cell(i, j).alignment = top_left
|
|
574
|
+
if long_cols:
|
|
575
|
+
longest = max(len(str(ws.cell(i, j).value or "")) for j in long_cols)
|
|
576
|
+
ws.row_dimensions[i].height = min(max(15, 15 * (longest // 55 + 1)), 400)
|
|
577
|
+
|
|
578
|
+
out = io.BytesIO()
|
|
579
|
+
wb.save(out)
|
|
580
|
+
return out.getvalue()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def report_file(rows: list[dict], prefs, config, now: datetime, title: str) -> Optional[tuple[str, bytes]]:
|
|
584
|
+
"""(filename, bytes) in the format the user picked, or None for "none"
|
|
585
|
+
or when building fails (the short message still goes out)."""
|
|
586
|
+
kind = FILE_TYPES.get(getattr(prefs, "smart_file", "pdf"))
|
|
587
|
+
if not kind or not rows:
|
|
588
|
+
return None
|
|
589
|
+
try:
|
|
590
|
+
doc = build_document(rows, prefs, config, now, title)
|
|
591
|
+
data = to_pdf(doc) if kind == "pdf" else to_xlsx(doc)
|
|
592
|
+
except Exception:
|
|
593
|
+
logger.exception("Smart report file could not be built")
|
|
594
|
+
return None
|
|
595
|
+
return f"MarketMyna-report-{now:%Y-%m-%d}.{kind}", data
|