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,639 @@
|
|
|
1
|
+
"""Three-statement analysis for one company: income statement, balance
|
|
2
|
+
sheet and cash flow statement. Indian shares use the company's own XBRL
|
|
3
|
+
filings on NSE (marketmyna/data/india_filings.py), falling back to Yahoo Finance;
|
|
4
|
+
everything else comes from Yahoo Finance, and US companies Yahoo has no
|
|
5
|
+
statements for fall back to their SEC XBRL filings (marketmyna/data/sec_facts.py).
|
|
6
|
+
|
|
7
|
+
For each statement this returns the key line items across the available
|
|
8
|
+
periods (normally 4-5 years, or the last 4-5 quarters), derived ratios,
|
|
9
|
+
and rule-based findings - each tagged good / bad / neutral - that roll up
|
|
10
|
+
into a per-statement verdict and a plain-English summary of the whole
|
|
11
|
+
company. An optional AI narrative (ai_summary) reads the same numbers.
|
|
12
|
+
|
|
13
|
+
Everything is best-effort: banks publish no gross profit or current
|
|
14
|
+
assets, many Indian filings lack some rows, so each missing value is None
|
|
15
|
+
and each finding is only emitted when its inputs exist.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ..core.numbers import finite as _num
|
|
26
|
+
from ..core.numbers import ratio as _div
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# (key, label, candidate yfinance row names in priority order)
|
|
31
|
+
INCOME_ROWS = [
|
|
32
|
+
("revenue", "Total revenue", ["Total Revenue", "Operating Revenue"]),
|
|
33
|
+
("cost_of_revenue", "Cost of revenue", ["Cost Of Revenue", "Reconciled Cost Of Revenue"]),
|
|
34
|
+
("gross_profit", "Gross profit", ["Gross Profit"]),
|
|
35
|
+
("operating_expense", "Operating expenses", ["Operating Expense"]),
|
|
36
|
+
("sga", "Selling, general & admin", ["Selling General And Administration"]),
|
|
37
|
+
("operating_income", "Operating income", ["Operating Income", "Total Operating Income As Reported"]),
|
|
38
|
+
("ebitda", "EBITDA", ["EBITDA", "Normalized EBITDA"]),
|
|
39
|
+
("interest_expense", "Interest expense", ["Interest Expense", "Interest Expense Non Operating"]),
|
|
40
|
+
("pretax_income", "Pre-tax income", ["Pretax Income"]),
|
|
41
|
+
("tax", "Tax", ["Tax Provision"]),
|
|
42
|
+
("net_income", "Net income", ["Net Income", "Net Income Common Stockholders"]),
|
|
43
|
+
("eps", "Diluted EPS", ["Diluted EPS", "Basic EPS"]),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
BALANCE_ROWS = [
|
|
47
|
+
("total_assets", "Total assets", ["Total Assets"]),
|
|
48
|
+
("current_assets", "Current assets", ["Current Assets"]),
|
|
49
|
+
("cash", "Cash & short-term investments", ["Cash Cash Equivalents And Short Term Investments", "Cash And Cash Equivalents"]),
|
|
50
|
+
("long_term_investments", "Long-term investments", ["Investments And Advances", "Long Term Equity Investment"]),
|
|
51
|
+
("receivables", "Receivables", ["Accounts Receivable", "Receivables"]),
|
|
52
|
+
("inventory", "Inventory", ["Inventory"]),
|
|
53
|
+
("ppe", "Property, plant & equipment", ["Net PPE"]),
|
|
54
|
+
("total_liabilities", "Total liabilities", ["Total Liabilities Net Minority Interest"]),
|
|
55
|
+
("current_liabilities", "Current liabilities", ["Current Liabilities"]),
|
|
56
|
+
("total_debt", "Total debt", ["Total Debt"]),
|
|
57
|
+
("net_debt", "Net debt", ["Net Debt"]),
|
|
58
|
+
("equity", "Shareholders' equity", ["Stockholders Equity", "Common Stock Equity", "Total Equity Gross Minority Interest"]),
|
|
59
|
+
("retained_earnings", "Retained earnings", ["Retained Earnings"]),
|
|
60
|
+
("goodwill", "Goodwill & intangibles", ["Goodwill And Other Intangible Assets", "Goodwill"]),
|
|
61
|
+
("shares", "Shares outstanding", ["Ordinary Shares Number", "Share Issued"]),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
CASHFLOW_ROWS = [
|
|
65
|
+
("operating_cf", "Operating cash flow", ["Operating Cash Flow", "Cash Flow From Continuing Operating Activities"]),
|
|
66
|
+
("capex", "Capital expenditure", ["Capital Expenditure", "Purchase Of PPE"]),
|
|
67
|
+
("sbc", "Stock-based compensation", ["Stock Based Compensation"]),
|
|
68
|
+
("free_cf", "Free cash flow", ["Free Cash Flow"]),
|
|
69
|
+
("investing_cf", "Investing cash flow", ["Investing Cash Flow", "Cash Flow From Continuing Investing Activities"]),
|
|
70
|
+
("financing_cf", "Financing cash flow", ["Financing Cash Flow", "Cash Flow From Continuing Financing Activities"]),
|
|
71
|
+
("dividends", "Dividends paid", ["Cash Dividends Paid", "Common Stock Dividend Paid"]),
|
|
72
|
+
("buybacks", "Share buybacks", ["Repurchase Of Capital Stock", "Common Stock Payments"]),
|
|
73
|
+
("debt_issued", "Debt issued", ["Issuance Of Debt", "Long Term Debt Issuance"]),
|
|
74
|
+
("debt_repaid", "Debt repaid", ["Repayment Of Debt", "Long Term Debt Payments"]),
|
|
75
|
+
("sbc", "Stock-based compensation", ["Stock Based Compensation"]),
|
|
76
|
+
("depreciation", "Depreciation & amortization", ["Depreciation And Amortization", "Depreciation Amortization Depletion", "Reconciled Depreciation"]),
|
|
77
|
+
("net_change_cash", "Net change in cash", ["Changes In Cash"]),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# Row keys shown as money; the rest (EPS, share count) are plain numbers.
|
|
81
|
+
_PLAIN_KEYS = {"eps", "shares"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _growth(new, old) -> Optional[float]:
|
|
85
|
+
"""Percent change; None when the base is missing or not positive
|
|
86
|
+
(growth off a loss is not meaningful)."""
|
|
87
|
+
if new is None or old is None or old <= 0:
|
|
88
|
+
return None
|
|
89
|
+
return (new / old - 1) * 100
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cagr(new, old, years) -> Optional[float]:
|
|
93
|
+
if new is None or old is None or old <= 0 or new <= 0 or years <= 0:
|
|
94
|
+
return None
|
|
95
|
+
return ((new / old) ** (1 / years) - 1) * 100
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract(df, rows, periods: list) -> dict[str, list[Optional[float]]]:
|
|
99
|
+
"""{key: [value per period, newest first]} from a yfinance statement."""
|
|
100
|
+
out = {}
|
|
101
|
+
for key, _, candidates in rows:
|
|
102
|
+
values = [None] * len(periods)
|
|
103
|
+
if df is not None and not df.empty:
|
|
104
|
+
for name in candidates:
|
|
105
|
+
if name in df.index:
|
|
106
|
+
series = df.loc[name]
|
|
107
|
+
values = [_num(series.get(p)) for p in periods]
|
|
108
|
+
if any(v is not None for v in values):
|
|
109
|
+
break
|
|
110
|
+
out[key] = values
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _periods(*frames) -> list:
|
|
115
|
+
"""Union of statement columns, newest first, dropping periods where
|
|
116
|
+
every statement is empty (yfinance pads with all-NaN columns)."""
|
|
117
|
+
cols = set()
|
|
118
|
+
for df in frames:
|
|
119
|
+
if df is not None and not df.empty:
|
|
120
|
+
for col in df.columns:
|
|
121
|
+
if df[col].notna().sum() >= 2:
|
|
122
|
+
cols.add(col)
|
|
123
|
+
return sorted(cols, reverse=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _table(rows, values: dict) -> list[dict]:
|
|
127
|
+
return [
|
|
128
|
+
{"key": key, "label": label, "kind": "number" if key in _PLAIN_KEYS else "money", "values": values[key]}
|
|
129
|
+
for key, label, _ in rows
|
|
130
|
+
if any(v is not None for v in values[key])
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _finding(tone: str, text: str) -> dict:
|
|
135
|
+
return {"tone": tone, "text": text}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _verdict(findings: list[dict]) -> dict:
|
|
139
|
+
"""0-100 score: good findings count fully, neutral ones half, bad none
|
|
140
|
+
("info" notes are context, not scored)."""
|
|
141
|
+
findings = [f for f in findings if f["tone"] != "info"]
|
|
142
|
+
good = sum(f["tone"] == "good" for f in findings)
|
|
143
|
+
bad = sum(f["tone"] == "bad" for f in findings)
|
|
144
|
+
neutral = len(findings) - good - bad
|
|
145
|
+
score = round(100 * (good + 0.5 * neutral) / len(findings)) if findings else None
|
|
146
|
+
label = "n/a" if score is None else "strong" if score >= 65 else "weak" if score < 40 else "mixed"
|
|
147
|
+
return {"score": score, "label": label, "good": good, "bad": bad, "neutral": neutral}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _fmt_money(v: Optional[float]) -> str:
|
|
151
|
+
if v is None:
|
|
152
|
+
return "n/a"
|
|
153
|
+
a = abs(v)
|
|
154
|
+
for size, suffix in ((1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
155
|
+
if a >= size:
|
|
156
|
+
return f"{v / size:.2f}{suffix}"
|
|
157
|
+
return f"{v:.0f}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------ analysis
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _compact(values: dict, periods: list, core: str) -> tuple[dict, list]:
|
|
164
|
+
"""Keep only the periods where this statement actually reports its core
|
|
165
|
+
row. Indian filers publish quarterly P&L but half-yearly balance sheets
|
|
166
|
+
and cash flows, so each statement gets its own timeline."""
|
|
167
|
+
keep = [i for i, v in enumerate(values[core]) if v is not None]
|
|
168
|
+
return {k: [vals[i] for i in keep] for k, vals in values.items()}, [periods[i] for i in keep]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _compare(dates: list, quarterly: bool) -> tuple[Optional[int], str]:
|
|
172
|
+
"""Index of the period to compare the latest against, by date: same
|
|
173
|
+
quarter a year earlier when present, else the prior quarter/year."""
|
|
174
|
+
if len(dates) < 2:
|
|
175
|
+
return None, ""
|
|
176
|
+
for i, d in enumerate(dates[1:], start=1):
|
|
177
|
+
if 300 <= (dates[0] - d).days <= 430:
|
|
178
|
+
return i, "same quarter last year" if quarterly else "prior year"
|
|
179
|
+
if quarterly and (dates[0] - dates[1]).days <= 200:
|
|
180
|
+
return 1, "prior period"
|
|
181
|
+
return None, ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _years_between(a, b) -> float:
|
|
185
|
+
return (a - b).days / 365.25
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _lookup(values: dict, periods: list):
|
|
189
|
+
"""Cross-statement lookup by period date: at('net_income', date)."""
|
|
190
|
+
index = {p: i for i, p in enumerate(periods)}
|
|
191
|
+
return lambda key, date: values[key][index[date]] if date in index else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _analyze_income(v: dict, dates: list, quarterly: bool) -> tuple[dict, list[dict]]:
|
|
195
|
+
n = len(dates)
|
|
196
|
+
rev, ni, op, gp = v["revenue"], v["net_income"], v["operating_income"], v["gross_profit"]
|
|
197
|
+
j, vs = _compare(dates, quarterly)
|
|
198
|
+
|
|
199
|
+
margins = {
|
|
200
|
+
"gross_margin": [_div(g, r) and _div(g, r) * 100 for g, r in zip(gp, rev)],
|
|
201
|
+
"operating_margin": [_div(o, r) and _div(o, r) * 100 for o, r in zip(op, rev)],
|
|
202
|
+
"net_margin": [_div(x, r) and _div(x, r) * 100 for x, r in zip(ni, rev)],
|
|
203
|
+
}
|
|
204
|
+
ebit = v["operating_income"][0] if n else None
|
|
205
|
+
interest = abs(v["interest_expense"][0]) if n and v["interest_expense"][0] else None
|
|
206
|
+
span = _years_between(dates[0], dates[-1]) if n > 1 else 0
|
|
207
|
+
metrics = {
|
|
208
|
+
"revenue_growth": _growth(rev[0], rev[j]) if j else None,
|
|
209
|
+
"net_income_growth": _growth(ni[0], ni[j]) if j else None,
|
|
210
|
+
"eps_growth": _growth(v["eps"][0], v["eps"][j]) if j else None,
|
|
211
|
+
"revenue_cagr": _cagr(rev[0], rev[-1], span) if span >= 1.5 else None,
|
|
212
|
+
"gross_margin": margins["gross_margin"][0] if n else None,
|
|
213
|
+
"operating_margin": margins["operating_margin"][0] if n else None,
|
|
214
|
+
"net_margin": margins["net_margin"][0] if n else None,
|
|
215
|
+
"interest_coverage": _div(ebit, interest),
|
|
216
|
+
"effective_tax_rate": _div(v["tax"][0], v["pretax_income"][0]) and _div(v["tax"][0], v["pretax_income"][0]) * 100 if n else None,
|
|
217
|
+
"compare_label": vs or None,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
f = []
|
|
221
|
+
g = metrics["revenue_growth"]
|
|
222
|
+
if g is not None:
|
|
223
|
+
if g >= 10:
|
|
224
|
+
f.append(_finding("good", f"Revenue grew {g:.1f}% vs {vs} to {_fmt_money(rev[0])}."))
|
|
225
|
+
elif g >= 0:
|
|
226
|
+
f.append(_finding("neutral", f"Revenue grew a modest {g:.1f}% vs {vs}."))
|
|
227
|
+
else:
|
|
228
|
+
f.append(_finding("bad", f"Revenue fell {abs(g):.1f}% vs {vs}."))
|
|
229
|
+
cagr = metrics["revenue_cagr"]
|
|
230
|
+
if cagr is not None:
|
|
231
|
+
tone = "good" if cagr >= 8 else "bad" if cagr < 0 else "neutral"
|
|
232
|
+
f.append(_finding(tone, f"{round(span)}-year revenue CAGR is {cagr:.1f}%."))
|
|
233
|
+
if n and ni[0] is not None:
|
|
234
|
+
if ni[0] < 0:
|
|
235
|
+
f.append(_finding("bad", f"The company is loss-making: net loss of {_fmt_money(abs(ni[0]))}."))
|
|
236
|
+
elif metrics["net_income_growth"] is not None:
|
|
237
|
+
ng = metrics["net_income_growth"]
|
|
238
|
+
tone = "good" if ng >= 10 else "bad" if ng < -10 else "neutral"
|
|
239
|
+
f.append(_finding(tone, f"Net profit {'rose' if ng >= 0 else 'fell'} {abs(ng):.1f}% vs {vs}."))
|
|
240
|
+
elif j and ni[j] is not None and ni[j] < 0:
|
|
241
|
+
f.append(_finding("good", "Returned to profit after a loss."))
|
|
242
|
+
nm = metrics["net_margin"]
|
|
243
|
+
if nm is not None:
|
|
244
|
+
tone = "good" if nm >= 15 else "bad" if nm < 5 else "neutral"
|
|
245
|
+
f.append(_finding(tone, f"Net margin is {nm:.1f}% (profit kept from each unit of revenue)."))
|
|
246
|
+
if j is not None and margins["operating_margin"][j] is not None and metrics["operating_margin"] is not None:
|
|
247
|
+
delta = metrics["operating_margin"] - margins["operating_margin"][j]
|
|
248
|
+
if abs(delta) >= 1:
|
|
249
|
+
f.append(_finding("good" if delta > 0 else "bad",
|
|
250
|
+
f"Operating margin {'expanded' if delta > 0 else 'contracted'} {abs(delta):.1f} pts to {metrics['operating_margin']:.1f}%."))
|
|
251
|
+
ic = metrics["interest_coverage"]
|
|
252
|
+
if ic is not None:
|
|
253
|
+
if ic >= 8:
|
|
254
|
+
f.append(_finding("good", f"Interest is covered {ic:.1f}x by operating profit - debt service is easy."))
|
|
255
|
+
elif ic < 3:
|
|
256
|
+
f.append(_finding("bad", f"Interest coverage is only {ic:.1f}x - earnings leave a thin cushion over interest costs."))
|
|
257
|
+
else:
|
|
258
|
+
f.append(_finding("neutral", f"Interest coverage is {ic:.1f}x."))
|
|
259
|
+
return {**metrics, "margins": margins}, f
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _analyze_balance(v: dict, dates: list, inc_at, quarterly: bool, financial: bool) -> tuple[dict, list[dict]]:
|
|
263
|
+
n = len(dates)
|
|
264
|
+
get = lambda k, i=0: v[k][i] if n > i else None # noqa: E731
|
|
265
|
+
equity, debt, cash = get("equity"), get("total_debt"), get("cash")
|
|
266
|
+
ca, cl = get("current_assets"), get("current_liabilities")
|
|
267
|
+
inventory = get("inventory") or 0
|
|
268
|
+
avg_equity = equity
|
|
269
|
+
if n > 1 and equity is not None and v["equity"][1] is not None and not quarterly:
|
|
270
|
+
avg_equity = (equity + v["equity"][1]) / 2
|
|
271
|
+
net_income = inc_at("net_income", dates[0]) if n else None
|
|
272
|
+
ebitda = inc_at("ebitda", dates[0]) if n else None
|
|
273
|
+
|
|
274
|
+
metrics = {
|
|
275
|
+
"current_ratio": _div(ca, cl),
|
|
276
|
+
"quick_ratio": _div(ca - inventory, cl) if ca is not None else None,
|
|
277
|
+
"debt_to_equity": _div(debt, equity) if equity and equity > 0 else None,
|
|
278
|
+
"debt_to_assets": _div(debt, get("total_assets")),
|
|
279
|
+
"net_debt": get("net_debt") if get("net_debt") is not None else (debt - cash if debt is not None and cash is not None else None),
|
|
280
|
+
"working_capital": ca - cl if ca is not None and cl is not None else None,
|
|
281
|
+
"roe": None if quarterly else (_div(net_income, avg_equity) * 100 if _div(net_income, avg_equity) is not None and avg_equity > 0 else None),
|
|
282
|
+
"roa": None if quarterly else (_div(net_income, get("total_assets")) and _div(net_income, get("total_assets")) * 100),
|
|
283
|
+
"book_value_per_share": _div(equity, get("shares")),
|
|
284
|
+
"equity_growth": _growth(equity, v["equity"][1]) if n > 1 else None,
|
|
285
|
+
"share_change": _growth(get("shares"), v["shares"][-1]) if n > 1 else None,
|
|
286
|
+
"goodwill_to_assets": _div(get("goodwill"), get("total_assets")) and _div(get("goodwill"), get("total_assets")) * 100,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
f = []
|
|
290
|
+
if financial:
|
|
291
|
+
f.append(_finding("info", "Lender/insurer: borrowing is its raw material, so debt/equity and liquidity ratios aren't comparable with other industries."))
|
|
292
|
+
cr = None if financial else metrics["current_ratio"]
|
|
293
|
+
if cr is not None:
|
|
294
|
+
if cr >= 1.5:
|
|
295
|
+
f.append(_finding("good", f"Current ratio {cr:.2f} - short-term assets comfortably cover short-term bills."))
|
|
296
|
+
elif cr < 1:
|
|
297
|
+
f.append(_finding("bad", f"Current ratio {cr:.2f} - current liabilities exceed current assets."))
|
|
298
|
+
else:
|
|
299
|
+
f.append(_finding("neutral", f"Current ratio {cr:.2f} - adequate liquidity."))
|
|
300
|
+
de = metrics["debt_to_equity"]
|
|
301
|
+
if equity is not None and equity <= 0:
|
|
302
|
+
f.append(_finding("bad", "Shareholders' equity is negative - liabilities exceed assets on the books."))
|
|
303
|
+
elif de is not None and not financial:
|
|
304
|
+
if de <= 0.5:
|
|
305
|
+
f.append(_finding("good", f"Low leverage: debt is {de:.2f}x equity."))
|
|
306
|
+
elif de >= 2:
|
|
307
|
+
f.append(_finding("bad", f"High leverage: debt is {de:.2f}x equity."))
|
|
308
|
+
else:
|
|
309
|
+
f.append(_finding("neutral", f"Moderate leverage: debt is {de:.2f}x equity."))
|
|
310
|
+
nd = metrics["net_debt"]
|
|
311
|
+
if financial:
|
|
312
|
+
pass
|
|
313
|
+
elif cash is not None and debt is not None and cash > debt:
|
|
314
|
+
f.append(_finding("good", f"Net cash position: cash {_fmt_money(cash)} exceeds debt {_fmt_money(debt)}."))
|
|
315
|
+
elif nd is not None and nd > 0 and ebitda and ebitda > 0 and not quarterly:
|
|
316
|
+
lev = nd / ebitda
|
|
317
|
+
tone = "good" if lev < 1.5 else "bad" if lev > 3.5 else "neutral"
|
|
318
|
+
f.append(_finding(tone, f"Net debt is {lev:.1f}x annual EBITDA."))
|
|
319
|
+
roe = metrics["roe"]
|
|
320
|
+
if roe is not None:
|
|
321
|
+
tone = "good" if roe >= 15 else "bad" if roe < 8 else "neutral"
|
|
322
|
+
f.append(_finding(tone, f"Return on equity is {roe:.1f}%."))
|
|
323
|
+
eg = metrics["equity_growth"]
|
|
324
|
+
if eg is not None and abs(eg) >= 3:
|
|
325
|
+
f.append(_finding("good" if eg > 0 else "bad",
|
|
326
|
+
f"Shareholders' equity {'grew' if eg > 0 else 'shrank'} {abs(eg):.1f}% vs the prior period."))
|
|
327
|
+
sc = metrics["share_change"]
|
|
328
|
+
if sc is not None and abs(sc) >= 2:
|
|
329
|
+
years = _years_between(dates[0], dates[-1])
|
|
330
|
+
span = f"over {years:.1f} years" if years >= 1 else "over the periods shown"
|
|
331
|
+
f.append(_finding("good" if sc < 0 else "bad",
|
|
332
|
+
f"Share count {'fell' if sc < 0 else 'rose'} {abs(sc):.1f}% {span} ({'buybacks' if sc < 0 else 'dilution'})."))
|
|
333
|
+
gw = metrics["goodwill_to_assets"]
|
|
334
|
+
if gw is not None and gw >= 30:
|
|
335
|
+
f.append(_finding("bad", f"Goodwill & intangibles are {gw:.0f}% of assets - acquisition-heavy balance sheet."))
|
|
336
|
+
return metrics, f
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _analyze_cashflow(v: dict, dates: list, inc_at, quarterly: bool, financial: bool) -> tuple[dict, list[dict]]:
|
|
340
|
+
n = len(dates)
|
|
341
|
+
# Indian filers publish half-year cash flows next to quarterly P&Ls;
|
|
342
|
+
# dividing one by the other would halve or double every ratio.
|
|
343
|
+
mismatched = quarterly and n > 1 and (dates[0] - dates[1]).days > 120
|
|
344
|
+
ocf = v["operating_cf"]
|
|
345
|
+
fcf = [f if f is not None else (o + c if o is not None and c is not None else None)
|
|
346
|
+
for f, o, c in zip(v["free_cf"], ocf, v["capex"])]
|
|
347
|
+
rev = [None if mismatched else inc_at("revenue", d) for d in dates]
|
|
348
|
+
ni = [None if mismatched else inc_at("net_income", d) for d in dates]
|
|
349
|
+
get = lambda series, i=0: series[i] if len(series) > i else None # noqa: E731
|
|
350
|
+
|
|
351
|
+
metrics = {
|
|
352
|
+
"free_cf": fcf[0] if fcf else None,
|
|
353
|
+
"fcf_margin": _div(get(fcf), get(rev)) and _div(get(fcf), get(rev)) * 100,
|
|
354
|
+
"cash_conversion": _div(get(ocf), get(ni)) if get(ni) and get(ni) > 0 else None,
|
|
355
|
+
"capex_to_revenue": _div(abs(get(v["capex"]) or 0), get(rev)) and _div(abs(get(v["capex"])), get(rev)) * 100 if get(v["capex"]) else None,
|
|
356
|
+
"shareholder_returns": sum(abs(x) for x in (get(v["dividends"]), get(v["buybacks"])) if x) or None,
|
|
357
|
+
"sbc_to_revenue": _div(get(v["sbc"]), get(rev)) and _div(get(v["sbc"]), get(rev)) * 100,
|
|
358
|
+
"positive_fcf_periods": sum(1 for x in fcf if x is not None and x > 0),
|
|
359
|
+
"fcf_periods": sum(1 for x in fcf if x is not None),
|
|
360
|
+
}
|
|
361
|
+
payout = _div(metrics["shareholder_returns"], get(fcf)) if get(fcf) and get(fcf) > 0 else None
|
|
362
|
+
metrics["fcf_payout"] = payout and payout * 100
|
|
363
|
+
|
|
364
|
+
f = []
|
|
365
|
+
if financial:
|
|
366
|
+
f.append(_finding("info", "Lender/insurer: loans and deposits run through operating cash flow, so free cash flow swings with lending and says little about profitability."))
|
|
367
|
+
return metrics, f
|
|
368
|
+
if get(ocf) is not None:
|
|
369
|
+
if get(ocf) > 0:
|
|
370
|
+
f.append(_finding("good", f"Operations generated {_fmt_money(get(ocf))} of cash."))
|
|
371
|
+
else:
|
|
372
|
+
f.append(_finding("bad", f"Operations burned {_fmt_money(abs(get(ocf)))} of cash."))
|
|
373
|
+
if get(fcf) is not None:
|
|
374
|
+
fm = metrics["fcf_margin"]
|
|
375
|
+
extra = f" ({fm:.1f}% of revenue)" if fm is not None else ""
|
|
376
|
+
f.append(_finding("good" if get(fcf) > 0 else "bad",
|
|
377
|
+
f"Free cash flow {'was' if get(fcf) > 0 else 'was negative at'} {_fmt_money(get(fcf))}{extra} after capex."))
|
|
378
|
+
pos, tot = metrics["positive_fcf_periods"], metrics["fcf_periods"]
|
|
379
|
+
if tot >= 3:
|
|
380
|
+
unit = "periods" if quarterly else "years"
|
|
381
|
+
if pos == tot:
|
|
382
|
+
f.append(_finding("good", f"Free cash flow positive in all {tot} {unit} shown - consistent cash generator."))
|
|
383
|
+
elif pos <= tot // 2:
|
|
384
|
+
f.append(_finding("bad", f"Free cash flow positive in only {pos} of {tot} {unit}."))
|
|
385
|
+
cc = metrics["cash_conversion"]
|
|
386
|
+
if cc is not None:
|
|
387
|
+
if cc >= 1:
|
|
388
|
+
f.append(_finding("good", f"Operating cash flow is {cc:.2f}x net income - earnings are backed by cash."))
|
|
389
|
+
elif cc < 0.7:
|
|
390
|
+
f.append(_finding("bad", f"Operating cash flow is only {cc:.2f}x net income - profits aren't fully turning into cash."))
|
|
391
|
+
else:
|
|
392
|
+
f.append(_finding("neutral", f"Operating cash flow is {cc:.2f}x net income."))
|
|
393
|
+
j, vs = _compare(dates, quarterly)
|
|
394
|
+
if j is not None and get(ocf) is not None and get(ocf, j) is not None:
|
|
395
|
+
g = _growth(get(ocf), get(ocf, j))
|
|
396
|
+
if g is not None and abs(g) >= 10:
|
|
397
|
+
f.append(_finding("good" if g > 0 else "bad", f"Operating cash flow {'rose' if g > 0 else 'fell'} {abs(g):.1f}% vs {vs}."))
|
|
398
|
+
payout = metrics["fcf_payout"]
|
|
399
|
+
if payout is not None:
|
|
400
|
+
if payout > 100:
|
|
401
|
+
f.append(_finding("bad", f"Dividends + buybacks ({_fmt_money(metrics['shareholder_returns'])}) are {payout:.0f}% of free cash flow - funded partly by cash reserves or debt."))
|
|
402
|
+
elif metrics["shareholder_returns"]:
|
|
403
|
+
f.append(_finding("neutral", f"Returned {_fmt_money(metrics['shareholder_returns'])} to shareholders ({payout:.0f}% of free cash flow)."))
|
|
404
|
+
sbc = metrics["sbc_to_revenue"]
|
|
405
|
+
if sbc is not None and sbc >= 10:
|
|
406
|
+
f.append(_finding("bad", f"Stock-based compensation is {sbc:.1f}% of revenue - a real cost that cash flow excludes."))
|
|
407
|
+
return metrics, f
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _summary(name: str, income: dict, balance: dict, cashflow: dict, overall: dict, period: str, financial: bool) -> str:
|
|
411
|
+
"""A few plain sentences a retail investor can read in 10 seconds."""
|
|
412
|
+
m_i, m_b, m_c = income["metrics"], balance["metrics"], cashflow["metrics"]
|
|
413
|
+
parts = []
|
|
414
|
+
g = m_i.get("revenue_growth")
|
|
415
|
+
nm = m_i.get("net_margin")
|
|
416
|
+
if g is not None or nm is not None:
|
|
417
|
+
bits = []
|
|
418
|
+
if g is not None:
|
|
419
|
+
bits.append(f"revenue {'up' if g >= 0 else 'down'} {abs(g):.1f}% vs {m_i['compare_label'] or 'prior period'}")
|
|
420
|
+
if nm is not None:
|
|
421
|
+
bits.append(f"a {nm:.1f}% net margin")
|
|
422
|
+
parts.append(f"Income statement ({income['verdict']['label']}): " + " with ".join(bits) + ".")
|
|
423
|
+
bs = []
|
|
424
|
+
if m_b.get("debt_to_equity") is not None and not financial:
|
|
425
|
+
bs.append(f"debt/equity {m_b['debt_to_equity']:.2f}")
|
|
426
|
+
if m_b.get("current_ratio") is not None:
|
|
427
|
+
bs.append(f"current ratio {m_b['current_ratio']:.2f}")
|
|
428
|
+
if m_b.get("roe") is not None:
|
|
429
|
+
bs.append(f"ROE {m_b['roe']:.1f}%")
|
|
430
|
+
if bs:
|
|
431
|
+
parts.append(f"Balance sheet ({balance['verdict']['label']}): " + ", ".join(bs) + ".")
|
|
432
|
+
fcf = m_c.get("free_cf")
|
|
433
|
+
cs = []
|
|
434
|
+
if fcf is not None:
|
|
435
|
+
cs.append(f"free cash flow {_fmt_money(fcf)}")
|
|
436
|
+
if m_c.get("cash_conversion") is not None:
|
|
437
|
+
cs.append(f"cash conversion {m_c['cash_conversion']:.2f}x")
|
|
438
|
+
if cs and not financial:
|
|
439
|
+
parts.append(f"Cash flow ({cashflow['verdict']['label']}): " + ", ".join(cs) + ".")
|
|
440
|
+
lead = {
|
|
441
|
+
"strong": f"{name} looks financially strong on its latest {period} filings.",
|
|
442
|
+
"weak": f"{name} shows financial weakness on its latest {period} filings.",
|
|
443
|
+
"mixed": f"{name} has a mixed financial picture on its latest {period} filings.",
|
|
444
|
+
}.get(overall["label"], f"Limited {period} statement data is published for {name}.")
|
|
445
|
+
return " ".join([lead, *parts])
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _statement(rows, values, periods, core, metrics, findings) -> dict:
|
|
449
|
+
dates = _compact(values, periods, core)[1]
|
|
450
|
+
return {
|
|
451
|
+
"as_of": str(dates[0])[:10] if dates else None,
|
|
452
|
+
"rows": _table(rows, values),
|
|
453
|
+
"metrics": metrics,
|
|
454
|
+
"findings": findings,
|
|
455
|
+
"verdict": _verdict(findings),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def build_financials(symbol: str, market: str, period: str = "annual") -> dict:
|
|
460
|
+
import yfinance as yf
|
|
461
|
+
|
|
462
|
+
from .analysis_run import yahoo_symbol
|
|
463
|
+
|
|
464
|
+
if market == "crypto":
|
|
465
|
+
raise ValueError("Financial statements are not applicable to crypto")
|
|
466
|
+
quarterly = period == "quarterly"
|
|
467
|
+
yf_symbol = yahoo_symbol(symbol, market)
|
|
468
|
+
ticker = yf.Ticker(yf_symbol)
|
|
469
|
+
source = "Reported financial statements (market data feed)"
|
|
470
|
+
filed = _india_filings(symbol, market, quarterly)
|
|
471
|
+
if filed is not None:
|
|
472
|
+
inc_df, bal_df, cf_df = filed
|
|
473
|
+
source = "Company filings on NSE (XBRL)"
|
|
474
|
+
elif quarterly:
|
|
475
|
+
inc_df, bal_df, cf_df = ticker.quarterly_income_stmt, ticker.quarterly_balance_sheet, ticker.quarterly_cashflow
|
|
476
|
+
else:
|
|
477
|
+
inc_df, bal_df, cf_df = ticker.income_stmt, ticker.balance_sheet, ticker.cashflow
|
|
478
|
+
|
|
479
|
+
periods = _periods(inc_df, bal_df, cf_df)
|
|
480
|
+
if not periods and market == "us":
|
|
481
|
+
sec = _sec_filings(symbol, quarterly)
|
|
482
|
+
if sec is not None:
|
|
483
|
+
inc_df, bal_df, cf_df = sec
|
|
484
|
+
source = "Company filings with the SEC (XBRL)"
|
|
485
|
+
periods = _periods(inc_df, bal_df, cf_df)
|
|
486
|
+
if not periods:
|
|
487
|
+
raise ValueError(f"No financial statements published for {yf_symbol}")
|
|
488
|
+
|
|
489
|
+
inc_v = _extract(inc_df, INCOME_ROWS, periods)
|
|
490
|
+
bal_v = _extract(bal_df, BALANCE_ROWS, periods)
|
|
491
|
+
cf_v = _extract(cf_df, CASHFLOW_ROWS, periods)
|
|
492
|
+
|
|
493
|
+
info = {}
|
|
494
|
+
try:
|
|
495
|
+
info = ticker.info or {}
|
|
496
|
+
except Exception as exc:
|
|
497
|
+
logger.info("Financials info for %s failed: %s", yf_symbol, exc)
|
|
498
|
+
name = info.get("longName") or info.get("shortName") or symbol
|
|
499
|
+
# Banks/insurers: no gross profit or current assets on the statements.
|
|
500
|
+
financial = info.get("sector") == "Financial Services" or (
|
|
501
|
+
all(x is None for x in inc_v["gross_profit"]) and all(x is None for x in bal_v["current_assets"])
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
inc_at = _lookup(inc_v, periods)
|
|
505
|
+
inc_m, inc_f = _analyze_income(*_compact(inc_v, periods, "revenue"), quarterly)
|
|
506
|
+
bal_m, bal_f = _analyze_balance(*_compact(bal_v, periods, "total_assets"), inc_at, quarterly, financial)
|
|
507
|
+
cf_m, cf_f = _analyze_cashflow(*_compact(cf_v, periods, "operating_cf"), inc_at, quarterly, financial)
|
|
508
|
+
|
|
509
|
+
income = _statement(INCOME_ROWS, inc_v, periods, "revenue", inc_m, inc_f)
|
|
510
|
+
balance = _statement(BALANCE_ROWS, bal_v, periods, "total_assets", bal_m, bal_f)
|
|
511
|
+
cashflow = _statement(CASHFLOW_ROWS, cf_v, periods, "operating_cf", cf_m, cf_f)
|
|
512
|
+
# Derived FCF fills the row when Yahoo omits "Free Cash Flow".
|
|
513
|
+
derived_fcf = [o + c if o is not None and c is not None else None for o, c in zip(cf_v["operating_cf"], cf_v["capex"])]
|
|
514
|
+
if not any(r["key"] == "free_cf" for r in cashflow["rows"]) and any(x is not None for x in derived_fcf):
|
|
515
|
+
cashflow["rows"].insert(2, {"key": "free_cf", "label": "Free cash flow", "kind": "money", "values": derived_fcf})
|
|
516
|
+
|
|
517
|
+
overall = _verdict(inc_f + bal_f + cf_f)
|
|
518
|
+
|
|
519
|
+
# NSE filings are always in rupees; Yahoo's financialCurrency describes Yahoo's own
|
|
520
|
+
# statements (USD for Infosys, Wipro and other US-listed Indian companies).
|
|
521
|
+
if source.startswith("Company filings on NSE"):
|
|
522
|
+
currency = "INR"
|
|
523
|
+
else:
|
|
524
|
+
currency = info.get("financialCurrency") or info.get("currency") or (
|
|
525
|
+
"USD" if source.startswith("Company filings with the SEC") else None)
|
|
526
|
+
|
|
527
|
+
quality = None
|
|
528
|
+
if not quarterly:
|
|
529
|
+
from .quality_scores import compute
|
|
530
|
+
|
|
531
|
+
# Market value only when the price and the filings share a currency
|
|
532
|
+
same_ccy = not currency or currency == info.get("currency")
|
|
533
|
+
quality = compute(inc_v, bal_v, cf_v, sector=info.get("sector"),
|
|
534
|
+
market_cap=info.get("marketCap") if same_ccy else None, financial=financial)
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
"symbol": symbol,
|
|
538
|
+
"market": market,
|
|
539
|
+
"name": name,
|
|
540
|
+
"period": "quarterly" if quarterly else "annual",
|
|
541
|
+
"currency": currency,
|
|
542
|
+
"sector": info.get("sector"),
|
|
543
|
+
"financial_sector": financial,
|
|
544
|
+
"periods": [str(p)[:10] for p in periods],
|
|
545
|
+
"income": income,
|
|
546
|
+
"balance": balance,
|
|
547
|
+
"cashflow": cashflow,
|
|
548
|
+
"overall": overall,
|
|
549
|
+
"summary": _summary(name, income, balance, cashflow, overall, "quarterly" if quarterly else "annual", financial),
|
|
550
|
+
"source": source,
|
|
551
|
+
"quality": quality,
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
_built: dict[tuple[str, str, str], tuple[float, dict]] = {}
|
|
556
|
+
_built_lock = threading.Lock()
|
|
557
|
+
BUILT_SECONDS = 6 * 3600
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cached_financials(symbol: str, market: str, period: str = "annual") -> dict:
|
|
561
|
+
"""build_financials, kept 6 hours: the statements card, the DCF card and the
|
|
562
|
+
investor checklists all read the same filings (which change quarterly)."""
|
|
563
|
+
key = (symbol, market, period)
|
|
564
|
+
with _built_lock:
|
|
565
|
+
hit = _built.get(key)
|
|
566
|
+
if hit and time.time() - hit[0] < BUILT_SECONDS:
|
|
567
|
+
return hit[1]
|
|
568
|
+
data = build_financials(symbol, market, period)
|
|
569
|
+
with _built_lock:
|
|
570
|
+
if len(_built) > 500:
|
|
571
|
+
_built.clear()
|
|
572
|
+
_built[key] = (time.time(), data)
|
|
573
|
+
return data
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _sec_filings(symbol: str, quarterly: bool):
|
|
577
|
+
"""(income, balance, cashflow) from SEC company facts when Yahoo has none,
|
|
578
|
+
or None (not an SEC filer, SEC_USER_AGENT unset, SEC unreachable)."""
|
|
579
|
+
from ..data import sec_facts
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
return sec_facts.statements(symbol, quarterly=quarterly, max_periods=5)
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
logger.info("SEC filings for %s unavailable: %s", symbol, exc)
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _india_filings(symbol: str, market: str, quarterly: bool):
|
|
589
|
+
"""(income, balance, cashflow) from NSE filings for an Indian share, or
|
|
590
|
+
None (other markets, indices, banks' layout, NSE unreachable...)."""
|
|
591
|
+
if market != "india" or symbol.startswith("^"):
|
|
592
|
+
return None
|
|
593
|
+
from ..data import india_filings
|
|
594
|
+
|
|
595
|
+
base = symbol.upper().removesuffix(".NS").removesuffix(".BO")
|
|
596
|
+
try:
|
|
597
|
+
return india_filings.statements(base, quarterly=quarterly)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
logger.info("NSE filings for %s unavailable, using Yahoo: %s", base, exc)
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def ai_summary(data: dict, config) -> Optional[str]:
|
|
604
|
+
"""LLM narrative over the computed statements. None when no LLM is
|
|
605
|
+
configured or the call fails - the rule-based summary still stands."""
|
|
606
|
+
from ..llm.channels import llm_configured
|
|
607
|
+
|
|
608
|
+
if config is None or not llm_configured(config):
|
|
609
|
+
return None
|
|
610
|
+
from ..llm.backend_factory import create_generation_backend
|
|
611
|
+
from ..llm.protocol import GenerationConfig
|
|
612
|
+
|
|
613
|
+
lines = [f"Company: {data['name']} ({data['symbol']}, {data['market']}), {data['period']} filings, "
|
|
614
|
+
f"currency {data.get('currency') or 'n/a'}. Periods (newest first): {', '.join(data['periods'])}."]
|
|
615
|
+
for key, title in (("income", "INCOME STATEMENT"), ("balance", "BALANCE SHEET"), ("cashflow", "CASH FLOW STATEMENT")):
|
|
616
|
+
st = data[key]
|
|
617
|
+
lines.append(f"\n{title}")
|
|
618
|
+
for row in st["rows"]:
|
|
619
|
+
vals = ", ".join(_fmt_money(x) if row["kind"] == "money" else ("n/a" if x is None else f"{x:.2f}") for x in row["values"])
|
|
620
|
+
lines.append(f"- {row['label']}: {vals}")
|
|
621
|
+
lines.append("Findings: " + " ".join(f["text"] for f in st["findings"]))
|
|
622
|
+
|
|
623
|
+
system = (
|
|
624
|
+
"You are a sell-side equity analyst writing for a retail investor. Using ONLY the numbers given, "
|
|
625
|
+
"write a concise analysis in Markdown with these sections: '### Income statement', "
|
|
626
|
+
"'### Balance sheet', '### Cash flow', '### Bottom line'. Each section 2-4 sentences: what "
|
|
627
|
+
"changed, why it matters, and any red flags (e.g. profits not backed by cash, rising leverage, "
|
|
628
|
+
"dilution). The bottom line gives an overall financial-health verdict (strong / mixed / weak) "
|
|
629
|
+
"and the 2-3 things to watch next. Consider the industry (a bank has no gross margin or current "
|
|
630
|
+
"ratio). No price targets, no buy/sell advice, no disclaimers."
|
|
631
|
+
)
|
|
632
|
+
try:
|
|
633
|
+
result = create_generation_backend(config).generate(
|
|
634
|
+
"\n".join(lines), GenerationConfig(max_tokens=900, timeout=110), system_prompt=system
|
|
635
|
+
)
|
|
636
|
+
return (result.text or "").strip() or None
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
logger.warning("Financials AI summary unavailable: %s", exc)
|
|
639
|
+
return None
|