marketmyna 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
marketmyna/data/india.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Indian market data fetcher using jugaad-data and Yahoo Finance."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from ..analysis.fundamental import yfinance_fundamentals
|
|
9
|
+
from ..core.constants import TimeFrame
|
|
10
|
+
from .base import DataFetcher
|
|
11
|
+
from .extra_sources import twelvedata_history, twelvedata_key, twelvedata_quote
|
|
12
|
+
from .resilience import FallbackChain
|
|
13
|
+
|
|
14
|
+
STALE_QUOTE_GAP = 0.10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def quote_from_bars(symbol: str, bars: pd.DataFrame) -> dict:
|
|
18
|
+
"""A quote from the latest daily bars (Yahoo history: Open/High/Low/Close/Volume)."""
|
|
19
|
+
bar = bars.iloc[-1]
|
|
20
|
+
price = float(bar["Close"])
|
|
21
|
+
prev = float(bars["Close"].iloc[-2]) if len(bars) > 1 else None
|
|
22
|
+
return {
|
|
23
|
+
"symbol": symbol,
|
|
24
|
+
"price": price,
|
|
25
|
+
"previous_close": prev,
|
|
26
|
+
"open": float(bar["Open"]),
|
|
27
|
+
"day_high": float(bar["High"]),
|
|
28
|
+
"day_low": float(bar["Low"]),
|
|
29
|
+
"volume": int(bar["Volume"]),
|
|
30
|
+
"change": price - prev if prev else 0,
|
|
31
|
+
"change_percent": (price - prev) / prev * 100 if prev else 0,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# A BSE history shorter than this is treated as missing (see get_history).
|
|
36
|
+
BSE_MIN_BARS = 30
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_bse(symbol: str) -> bool:
|
|
40
|
+
"""RELIANCE.BO = the BSE listing; RELIANCE or RELIANCE.NS = NSE."""
|
|
41
|
+
return symbol.upper().endswith(".BO")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IndiaDataFetcher(DataFetcher):
|
|
45
|
+
"""Fetches Indian stock market data (NSE/BSE)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._cache = {}
|
|
49
|
+
# Try to import jugaad_data, fall back to yfinance if not available
|
|
50
|
+
try:
|
|
51
|
+
from jugaad_data.nse import NSELive, stock_df
|
|
52
|
+
self._nse_live = NSELive()
|
|
53
|
+
self._stock_df = stock_df
|
|
54
|
+
self._use_jugaad = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
self._use_jugaad = False
|
|
57
|
+
|
|
58
|
+
def _get_yf_symbol(self, symbol: str) -> str:
|
|
59
|
+
"""Convert to Yahoo Finance format for Indian stocks."""
|
|
60
|
+
# Don't modify index symbols (they start with ^)
|
|
61
|
+
if symbol.startswith("^"):
|
|
62
|
+
return symbol
|
|
63
|
+
if not symbol.endswith(".NS") and not symbol.endswith(".BO"):
|
|
64
|
+
return f"{symbol}.NS" # Default to NSE
|
|
65
|
+
return symbol
|
|
66
|
+
|
|
67
|
+
def _fetch_quote_jugaad(self, symbol: str) -> dict:
|
|
68
|
+
quote = self._nse_live.stock_quote(symbol.replace(".NS", "").replace(".BO", ""))
|
|
69
|
+
price_info = quote.get("priceInfo", {})
|
|
70
|
+
return {
|
|
71
|
+
"symbol": symbol,
|
|
72
|
+
"price": price_info.get("lastPrice", 0),
|
|
73
|
+
"previous_close": price_info.get("previousClose", 0),
|
|
74
|
+
"open": price_info.get("open", 0),
|
|
75
|
+
"day_high": price_info.get("intraDayHighLow", {}).get("max", 0),
|
|
76
|
+
"day_low": price_info.get("intraDayHighLow", {}).get("min", 0),
|
|
77
|
+
"volume": quote.get("securityWiseDP", {}).get("quantityTraded", 0),
|
|
78
|
+
"change": price_info.get("change", 0),
|
|
79
|
+
"change_percent": price_info.get("pChange", 0),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def _fetch_quote_yfinance(self, symbol: str) -> dict:
|
|
83
|
+
import yfinance as yf
|
|
84
|
+
ticker = yf.Ticker(self._get_yf_symbol(symbol))
|
|
85
|
+
info = ticker.fast_info
|
|
86
|
+
try:
|
|
87
|
+
last, prev = info.last_price, info.previous_close
|
|
88
|
+
except KeyError:
|
|
89
|
+
# Yahoo has no such symbol (a typo, a fund name): an empty answer,
|
|
90
|
+
# not a source failure, so it can't trip the breaker for every stock.
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
# Yahoo's live quote for some listings (REITs, InvITs) is frozen at
|
|
94
|
+
# an old date while its daily bars stay current. A move of more than
|
|
95
|
+
# 10% is checked against the bars; if they disagree, the bars win.
|
|
96
|
+
if last and prev and abs(last / prev - 1) > STALE_QUOTE_GAP:
|
|
97
|
+
bars = ticker.history(period="5d", interval="1d")
|
|
98
|
+
if not bars.empty and abs(last / float(bars["Close"].iloc[-1]) - 1) > STALE_QUOTE_GAP:
|
|
99
|
+
return quote_from_bars(symbol, bars)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"symbol": symbol,
|
|
103
|
+
"price": last,
|
|
104
|
+
"previous_close": prev,
|
|
105
|
+
"open": info.open,
|
|
106
|
+
"day_high": info.day_high,
|
|
107
|
+
"day_low": info.day_low,
|
|
108
|
+
"volume": info.last_volume,
|
|
109
|
+
"change": last - prev if prev else 0,
|
|
110
|
+
"change_percent": ((last - prev) / prev) * 100 if prev else 0,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def get_quote(self, symbol: str) -> dict:
|
|
114
|
+
"""Get current quote for an Indian stock."""
|
|
115
|
+
chain = FallbackChain("india.get_quote")
|
|
116
|
+
# jugaad-data reads NSE only: a BSE symbol (.BO) goes straight to Yahoo.
|
|
117
|
+
if self._use_jugaad and not is_bse(symbol):
|
|
118
|
+
chain.add("jugaad", self._fetch_quote_jugaad)
|
|
119
|
+
chain.add("yfinance", self._fetch_quote_yfinance)
|
|
120
|
+
if not symbol.startswith("^") and twelvedata_key():
|
|
121
|
+
chain.add("twelvedata", lambda s: twelvedata_quote(s, "india"))
|
|
122
|
+
return chain.call(symbol, is_empty=lambda d: not d or not d.get("price"))
|
|
123
|
+
|
|
124
|
+
_PERIOD_DAYS = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365, "2y": 730, "5y": 1825}
|
|
125
|
+
|
|
126
|
+
def _fetch_history_jugaad(
|
|
127
|
+
self,
|
|
128
|
+
symbol: str,
|
|
129
|
+
period: str = "1y",
|
|
130
|
+
start: Optional[datetime] = None,
|
|
131
|
+
end: Optional[datetime] = None,
|
|
132
|
+
**_ignored,
|
|
133
|
+
) -> pd.DataFrame:
|
|
134
|
+
clean_symbol = symbol.replace(".NS", "").replace(".BO", "")
|
|
135
|
+
range_start = start or (datetime.now() - timedelta(days=self._PERIOD_DAYS.get(period, 365)))
|
|
136
|
+
range_end = end or datetime.now()
|
|
137
|
+
|
|
138
|
+
df = self._stock_df(symbol=clean_symbol, from_date=range_start.date(), to_date=range_end.date())
|
|
139
|
+
if df.empty:
|
|
140
|
+
return df
|
|
141
|
+
|
|
142
|
+
df = df.rename(
|
|
143
|
+
columns={
|
|
144
|
+
"DATE": "date",
|
|
145
|
+
"OPEN": "open",
|
|
146
|
+
"HIGH": "high",
|
|
147
|
+
"LOW": "low",
|
|
148
|
+
"CLOSE": "close",
|
|
149
|
+
"LTP": "ltp",
|
|
150
|
+
"VOLUME": "volume",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
df.set_index("date", inplace=True)
|
|
154
|
+
return df
|
|
155
|
+
|
|
156
|
+
def get_history(
|
|
157
|
+
self,
|
|
158
|
+
symbol: str,
|
|
159
|
+
period: str = "1y",
|
|
160
|
+
interval: TimeFrame = TimeFrame.DAY_1,
|
|
161
|
+
start: Optional[datetime] = None,
|
|
162
|
+
end: Optional[datetime] = None,
|
|
163
|
+
) -> pd.DataFrame:
|
|
164
|
+
"""Get historical price data for an Indian stock."""
|
|
165
|
+
chain = FallbackChain("india.get_history")
|
|
166
|
+
if self._use_jugaad and interval == TimeFrame.DAY_1 and not is_bse(symbol):
|
|
167
|
+
chain.add("jugaad", self._fetch_history_jugaad)
|
|
168
|
+
chain.add("yfinance", self._fetch_history_yfinance)
|
|
169
|
+
if not symbol.startswith("^") and twelvedata_key():
|
|
170
|
+
chain.add("twelvedata", lambda s, **kw: twelvedata_history(s, "india", **kw))
|
|
171
|
+
df = chain.call(
|
|
172
|
+
symbol, period=period, interval=interval, start=start, end=end, is_empty=lambda df: df.empty
|
|
173
|
+
)
|
|
174
|
+
if is_bse(symbol) and len(df) < BSE_MIN_BARS:
|
|
175
|
+
# Yahoo often has only the last day or two for a BSE listing
|
|
176
|
+
# (RELIANCE.BO). The same company's NSE history trades within
|
|
177
|
+
# paise of it, so charts, indicators and levels use that instead.
|
|
178
|
+
try:
|
|
179
|
+
nse = self.get_history(symbol[:-3], period=period, interval=interval, start=start, end=end)
|
|
180
|
+
except Exception:
|
|
181
|
+
nse = None
|
|
182
|
+
if nse is not None and len(nse) > len(df):
|
|
183
|
+
nse.attrs["history_from"] = "NSE"
|
|
184
|
+
return nse
|
|
185
|
+
return df
|
|
186
|
+
|
|
187
|
+
def _fetch_history_yfinance(self, symbol: str, **kwargs) -> pd.DataFrame:
|
|
188
|
+
from .stocks import StockDataFetcher
|
|
189
|
+
|
|
190
|
+
return StockDataFetcher()._fetch_history_yfinance(self._get_yf_symbol(symbol), **kwargs)
|
|
191
|
+
|
|
192
|
+
def search(self, query: str) -> list[dict]:
|
|
193
|
+
"""Search for Indian stocks matching query."""
|
|
194
|
+
# Use yfinance for search
|
|
195
|
+
import yfinance as yf
|
|
196
|
+
|
|
197
|
+
results = []
|
|
198
|
+
for suffix in [".NS", ".BO"]:
|
|
199
|
+
try:
|
|
200
|
+
ticker = yf.Ticker(f"{query}{suffix}")
|
|
201
|
+
info = ticker.info
|
|
202
|
+
if info and info.get("symbol"):
|
|
203
|
+
results.append(
|
|
204
|
+
{
|
|
205
|
+
"symbol": info.get("symbol"),
|
|
206
|
+
"name": info.get("longName", info.get("shortName", "")),
|
|
207
|
+
"exchange": "NSE" if suffix == ".NS" else "BSE",
|
|
208
|
+
"type": info.get("quoteType", "EQUITY"),
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
return results
|
|
215
|
+
|
|
216
|
+
def get_info(self, symbol: str) -> dict:
|
|
217
|
+
"""Get detailed information about an Indian stock."""
|
|
218
|
+
import yfinance as yf
|
|
219
|
+
|
|
220
|
+
ticker = yf.Ticker(self._get_yf_symbol(symbol))
|
|
221
|
+
info = ticker.info
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"symbol": info.get("symbol", symbol),
|
|
225
|
+
"name": info.get("longName", ""),
|
|
226
|
+
"sector": info.get("sector", ""),
|
|
227
|
+
"industry": info.get("industry", ""),
|
|
228
|
+
"description": info.get("longBusinessSummary", ""),
|
|
229
|
+
"currency": "INR",
|
|
230
|
+
"market_cap": info.get("marketCap", 0),
|
|
231
|
+
"pe_ratio": info.get("trailingPE", 0),
|
|
232
|
+
"pb_ratio": info.get("priceToBook", 0),
|
|
233
|
+
"dividend_yield": info.get("dividendYield", 0),
|
|
234
|
+
"52_week_high": info.get("fiftyTwoWeekHigh", 0),
|
|
235
|
+
"52_week_low": info.get("fiftyTwoWeekLow", 0),
|
|
236
|
+
"book_value": info.get("bookValue", 0),
|
|
237
|
+
"face_value": info.get("faceValue", 0),
|
|
238
|
+
# Normalized ratios for FundamentalAnalyzer (overrides the raw
|
|
239
|
+
# yfinance-unit fields above, e.g. dividend_yield)
|
|
240
|
+
**yfinance_fundamentals(info),
|
|
241
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Indian financial statements from the companies' own XBRL filings on NSE.
|
|
2
|
+
|
|
3
|
+
Every listed company files its results with the exchange in XBRL (SEBI's
|
|
4
|
+
Ind-AS taxonomy), so these are the official numbers, the same ones data
|
|
5
|
+
sites compile from. Two NSE feeds cover the history:
|
|
6
|
+
|
|
7
|
+
- corporates-financial-results (Quarterly / Annual): 2005 to Dec 2024;
|
|
8
|
+
- integrated-filing-results (Financials): SEBI's integrated filing, 2025 on.
|
|
9
|
+
|
|
10
|
+
Quarterly filings carry the profit and loss for the quarter; the March
|
|
11
|
+
filing also carries the full year, the year-end balance sheet and the
|
|
12
|
+
year's cash flows (the September one the half-year balance sheet).
|
|
13
|
+
|
|
14
|
+
statements() returns three DataFrames shaped like yfinance's statements
|
|
15
|
+
(rows named as yfinance names them, one column per period end, newest
|
|
16
|
+
first), so services/financials.py analyses them unchanged. Filings never
|
|
17
|
+
change once published, so each parsed file is cached on disk for good;
|
|
18
|
+
the filing lists are cached for a few hours. Anything unreadable raises,
|
|
19
|
+
and the caller falls back to Yahoo.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from datetime import date, datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
from xml.etree import ElementTree as ET
|
|
34
|
+
|
|
35
|
+
import pandas as pd
|
|
36
|
+
import requests
|
|
37
|
+
|
|
38
|
+
from marketmyna.core.home import app_home
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
API = "https://www.nseindia.com/api"
|
|
43
|
+
_HEADERS = {
|
|
44
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36",
|
|
45
|
+
"Referer": "https://www.nseindia.com/",
|
|
46
|
+
"Accept": "application/json, text/plain, */*",
|
|
47
|
+
}
|
|
48
|
+
_TIMEOUT = 25
|
|
49
|
+
LIST_TTL = 6 * 3600
|
|
50
|
+
QUARTERS = 8
|
|
51
|
+
PARSE_VERSION = 2
|
|
52
|
+
YEARS = 5
|
|
53
|
+
|
|
54
|
+
_lock = threading.Lock()
|
|
55
|
+
_lists: dict[str, tuple[float, list[dict]]] = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cache_dir() -> Path:
|
|
59
|
+
path = app_home() / "india_filings"
|
|
60
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get(url: str) -> requests.Response:
|
|
65
|
+
resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
return resp
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------ filing lists
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _day(text: str) -> Optional[date]:
|
|
74
|
+
for fmt in ("%d-%b-%Y", "%d-%B-%Y"):
|
|
75
|
+
try:
|
|
76
|
+
return datetime.strptime(text.strip().title(), fmt).date()
|
|
77
|
+
except (AttributeError, ValueError):
|
|
78
|
+
continue
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def filings(symbol: str) -> list[dict]:
|
|
83
|
+
"""[{end, consolidated, url}] for every results filing, newest first."""
|
|
84
|
+
symbol = symbol.upper()
|
|
85
|
+
with _lock:
|
|
86
|
+
hit = _lists.get(symbol)
|
|
87
|
+
if hit and time.time() - hit[0] < LIST_TTL:
|
|
88
|
+
return hit[1]
|
|
89
|
+
out: dict[tuple, dict] = {}
|
|
90
|
+
|
|
91
|
+
def add(end: Optional[date], consolidated: str, url: Optional[str]):
|
|
92
|
+
if end and url and url.endswith(".xml"):
|
|
93
|
+
key = (end, consolidated.lower().startswith("consolidated"))
|
|
94
|
+
out.setdefault(key, {"end": end, "consolidated": key[1], "url": url})
|
|
95
|
+
|
|
96
|
+
errors = []
|
|
97
|
+
try:
|
|
98
|
+
data = _get(f"{API}/integrated-filing-results?symbol={symbol}&type=Integrated%20Filing-%20Financials").json()
|
|
99
|
+
for r in data.get("data") or []:
|
|
100
|
+
add(_day(r.get("qe_Date") or ""), r.get("consolidated") or "", r.get("xbrl"))
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
errors.append(exc)
|
|
103
|
+
for period in ("Annual", "Quarterly"):
|
|
104
|
+
try:
|
|
105
|
+
for r in _get(f"{API}/corporates-financial-results?index=equities&symbol={symbol}&period={period}").json():
|
|
106
|
+
add(_day(r.get("toDate") or ""), r.get("consolidated") or "", r.get("xbrl"))
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
errors.append(exc)
|
|
109
|
+
if not out:
|
|
110
|
+
raise RuntimeError(f"No NSE results filings for {symbol}: {errors[:1]}")
|
|
111
|
+
rows = sorted(out.values(), key=lambda r: r["end"], reverse=True)
|
|
112
|
+
with _lock:
|
|
113
|
+
_lists[symbol] = (time.time(), rows)
|
|
114
|
+
return rows
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ XBRL
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse(xml: bytes) -> list[tuple[str, Optional[str], str, float]]:
|
|
121
|
+
"""[(tag, start or None for an instant, end, value)] for the facts with
|
|
122
|
+
no dimension (the company totals, not segments or breakdowns)."""
|
|
123
|
+
root = ET.fromstring(xml)
|
|
124
|
+
periods: dict[str, tuple[Optional[str], str]] = {}
|
|
125
|
+
for ctx in root:
|
|
126
|
+
if not ctx.tag.endswith("context"):
|
|
127
|
+
continue
|
|
128
|
+
if any(el.tag.endswith(("explicitMember", "typedMember")) for el in ctx.iter()):
|
|
129
|
+
continue
|
|
130
|
+
start = ctx.find(".//{*}startDate")
|
|
131
|
+
end = ctx.find(".//{*}endDate")
|
|
132
|
+
instant = ctx.find(".//{*}instant")
|
|
133
|
+
if instant is not None and instant.text:
|
|
134
|
+
periods[ctx.get("id")] = (None, instant.text.strip())
|
|
135
|
+
elif start is not None and end is not None and start.text and end.text:
|
|
136
|
+
begin, stop = start.text.strip(), end.text.strip()
|
|
137
|
+
if ctx.get("id") == "FourD":
|
|
138
|
+
# "FourD" is the year to date. Filings before 2025 give it the
|
|
139
|
+
# quarter's dates by mistake; it always starts on 1 April.
|
|
140
|
+
last = date.fromisoformat(stop)
|
|
141
|
+
begin = date(last.year if last.month >= 4 else last.year - 1, 4, 1).isoformat()
|
|
142
|
+
periods[ctx.get("id")] = (begin, stop)
|
|
143
|
+
facts = []
|
|
144
|
+
for el in root:
|
|
145
|
+
ref = el.get("contextRef")
|
|
146
|
+
text = (el.text or "").strip()
|
|
147
|
+
if ref not in periods or not re.fullmatch(r"-?\d+(\.\d+)?", text):
|
|
148
|
+
continue
|
|
149
|
+
start, end = periods[ref]
|
|
150
|
+
facts.append((el.tag.split("}")[-1], start, end, float(text)))
|
|
151
|
+
return facts
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _facts(url: str) -> list[tuple]:
|
|
155
|
+
# The version prefix retires cached parses when parse() changes.
|
|
156
|
+
path = _cache_dir() / (hashlib.sha256(f"{PARSE_VERSION}:{url}".encode()).hexdigest()[:24] + ".json")
|
|
157
|
+
try:
|
|
158
|
+
return [tuple(f) for f in json.loads(path.read_text(encoding="utf-8"))]
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
pass
|
|
161
|
+
facts = parse(_get(url).content)
|
|
162
|
+
tmp = path.with_suffix(".tmp")
|
|
163
|
+
tmp.write_text(json.dumps(facts), encoding="utf-8")
|
|
164
|
+
tmp.replace(path)
|
|
165
|
+
return facts
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------ statements
|
|
169
|
+
|
|
170
|
+
# yfinance row name -> how to read it from one period's facts {tag: value}.
|
|
171
|
+
def _sum(v: dict, *tags: str) -> Optional[float]:
|
|
172
|
+
vals = [v[t] for t in tags if t in v]
|
|
173
|
+
return sum(vals) if vals else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _first(v: dict, *tags: str) -> Optional[float]:
|
|
177
|
+
return next((v[t] for t in tags if t in v), None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _minus(a: Optional[float], b: Optional[float]) -> Optional[float]:
|
|
181
|
+
return a - b if a is not None and b is not None else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _income(v: dict) -> dict:
|
|
185
|
+
revenue = v.get("RevenueFromOperations")
|
|
186
|
+
cost = _sum(v, "CostOfMaterialsConsumed", "PurchasesOfStockInTrade",
|
|
187
|
+
"ChangesInInventoriesOfFinishedGoodsWorkInProgressAndStockInTrade")
|
|
188
|
+
pbt_before_exceptional = _first(v, "ProfitBeforeExceptionalItemsAndTax", "ProfitBeforeTax")
|
|
189
|
+
finance = v.get("FinanceCosts")
|
|
190
|
+
operating = None
|
|
191
|
+
if pbt_before_exceptional is not None:
|
|
192
|
+
operating = pbt_before_exceptional + (finance or 0) - (v.get("OtherIncome") or 0)
|
|
193
|
+
depreciation = v.get("DepreciationDepletionAndAmortisationExpense")
|
|
194
|
+
return {
|
|
195
|
+
"Total Revenue": revenue,
|
|
196
|
+
"Cost Of Revenue": cost,
|
|
197
|
+
"Gross Profit": _minus(revenue, cost),
|
|
198
|
+
"Operating Income": operating,
|
|
199
|
+
"EBITDA": operating + depreciation if operating is not None and depreciation is not None else None,
|
|
200
|
+
"Interest Expense": finance,
|
|
201
|
+
# Overheads: Ind-AS has no SG&A line; staff plus other expenses stand in
|
|
202
|
+
"Selling General And Administration": _sum(v, "EmployeeBenefitExpense", "OtherExpenses"),
|
|
203
|
+
"Pretax Income": v.get("ProfitBeforeTax"),
|
|
204
|
+
"Tax Provision": v.get("TaxExpense"),
|
|
205
|
+
"Net Income": _first(v, "ProfitOrLossAttributableToOwnersOfParent", "ProfitLossForPeriod"),
|
|
206
|
+
"Diluted EPS": _first(v, "DilutedEarningsLossPerShareFromContinuingAndDiscontinuedOperations",
|
|
207
|
+
"DilutedEarningsLossPerShareFromContinuingOperations"),
|
|
208
|
+
"Basic EPS": _first(v, "BasicEarningsLossPerShareFromContinuingAndDiscontinuedOperations",
|
|
209
|
+
"BasicEarningsLossPerShareFromContinuingOperations"),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _balance(v: dict) -> dict:
|
|
214
|
+
debt = _sum(v, "BorrowingsNoncurrent", "BorrowingsCurrent")
|
|
215
|
+
cash = v.get("CashAndCashEquivalents")
|
|
216
|
+
paid_up, face = v.get("PaidUpValueOfEquityShareCapital") or v.get("EquityShareCapital"), v.get("FaceValueOfEquityShareCapital")
|
|
217
|
+
return {
|
|
218
|
+
"Total Assets": v.get("Assets"),
|
|
219
|
+
"Current Assets": v.get("CurrentAssets"),
|
|
220
|
+
"Cash And Cash Equivalents": cash,
|
|
221
|
+
"Accounts Receivable": v.get("TradeReceivablesCurrent"),
|
|
222
|
+
"Inventory": v.get("Inventories"),
|
|
223
|
+
"Total Liabilities Net Minority Interest": v.get("Liabilities"),
|
|
224
|
+
"Current Liabilities": v.get("CurrentLiabilities"),
|
|
225
|
+
"Total Debt": debt,
|
|
226
|
+
"Net Debt": _minus(debt, cash),
|
|
227
|
+
"Stockholders Equity": _first(v, "EquityAttributableToOwnersOfParent", "Equity"),
|
|
228
|
+
"Goodwill And Other Intangible Assets": _sum(v, "Goodwill", "OtherIntangibleAssets"),
|
|
229
|
+
"Net PPE": v.get("PropertyPlantAndEquipment"),
|
|
230
|
+
# Ind-AS "other equity" is reserves and surplus, mostly retained earnings
|
|
231
|
+
"Retained Earnings": _first(v, "RetainedEarnings", "OtherEquity"),
|
|
232
|
+
"Ordinary Shares Number": paid_up / face if paid_up and face else None,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _cashflow(v: dict) -> dict:
|
|
237
|
+
operating = v.get("CashFlowsFromUsedInOperatingActivities")
|
|
238
|
+
purchase = v.get("PurchaseOfPropertyPlantAndEquipmentClassifiedAsInvestingActivities")
|
|
239
|
+
capex = -abs(purchase) if purchase is not None else None
|
|
240
|
+
dividends = v.get("DividendsPaidClassifiedAsFinancingActivities")
|
|
241
|
+
return {
|
|
242
|
+
"Operating Cash Flow": operating,
|
|
243
|
+
"Capital Expenditure": capex,
|
|
244
|
+
"Free Cash Flow": operating + capex if operating is not None and capex is not None else None,
|
|
245
|
+
"Investing Cash Flow": v.get("CashFlowsFromUsedInInvestingActivities"),
|
|
246
|
+
"Depreciation And Amortization": _first(v, "AdjustmentsForDepreciationAndAmortisationExpense",
|
|
247
|
+
"DepreciationDepletionAndAmortisationExpense"),
|
|
248
|
+
"Financing Cash Flow": v.get("CashFlowsFromUsedInFinancingActivities"),
|
|
249
|
+
"Cash Dividends Paid": -abs(dividends) if dividends is not None else None,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _span_days(start: str, end: str) -> int:
|
|
254
|
+
return (date.fromisoformat(end) - date.fromisoformat(start)).days
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _period_values(facts: list[tuple], end: date, span: tuple[int, int]) -> tuple[dict, dict]:
|
|
258
|
+
"""(duration facts covering `span` days and ending at `end`, instant facts at `end`)."""
|
|
259
|
+
iso = end.isoformat()
|
|
260
|
+
durations, instants = {}, {}
|
|
261
|
+
for tag, start, stop, value in facts:
|
|
262
|
+
if stop != iso:
|
|
263
|
+
continue
|
|
264
|
+
if start is None:
|
|
265
|
+
instants.setdefault(tag, value)
|
|
266
|
+
elif span[0] <= _span_days(start, stop) <= span[1]:
|
|
267
|
+
durations.setdefault(tag, value)
|
|
268
|
+
return durations, instants
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
QUARTER_DAYS = (80, 100)
|
|
272
|
+
YEAR_DAYS = (350, 380)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _frame(columns: dict[date, dict]) -> pd.DataFrame:
|
|
276
|
+
"""yfinance-shaped: rows = line items, columns = period ends (newest first)."""
|
|
277
|
+
if not columns:
|
|
278
|
+
return pd.DataFrame()
|
|
279
|
+
df = pd.DataFrame({pd.Timestamp(d): col for d, col in columns.items()})
|
|
280
|
+
df = df[sorted(df.columns, reverse=True)]
|
|
281
|
+
return df.dropna(how="all")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def statements(symbol: str, quarterly: bool = False) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
285
|
+
"""(income, balance, cashflow) from NSE filings, consolidated when the
|
|
286
|
+
company files consolidated results, else standalone."""
|
|
287
|
+
rows = filings(symbol)
|
|
288
|
+
consolidated = any(r["consolidated"] for r in rows[:4])
|
|
289
|
+
rows = [r for r in rows if r["consolidated"] == consolidated]
|
|
290
|
+
if quarterly:
|
|
291
|
+
rows = rows[:QUARTERS]
|
|
292
|
+
span = QUARTER_DAYS
|
|
293
|
+
else:
|
|
294
|
+
rows = [r for r in rows if r["end"].month == 3 and r["end"].day == 31][:YEARS]
|
|
295
|
+
span = YEAR_DAYS
|
|
296
|
+
if not rows:
|
|
297
|
+
raise RuntimeError(f"No {'quarterly' if quarterly else 'annual'} filings for {symbol}")
|
|
298
|
+
|
|
299
|
+
income, balance, cashflow = {}, {}, {}
|
|
300
|
+
shares: dict[date, float] = {}
|
|
301
|
+
for r in rows:
|
|
302
|
+
try:
|
|
303
|
+
facts = _facts(r["url"])
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
logger.info("NSE filing %s unreadable: %s", r["url"], exc)
|
|
306
|
+
continue
|
|
307
|
+
durations, instants = _period_values(facts, r["end"], span)
|
|
308
|
+
if durations.get("RevenueFromOperations") is not None:
|
|
309
|
+
income[r["end"]] = _income(durations)
|
|
310
|
+
count = _share_count({**instants, **durations})
|
|
311
|
+
if count:
|
|
312
|
+
shares[r["end"]] = count
|
|
313
|
+
if instants.get("Assets") is not None:
|
|
314
|
+
balance[r["end"]] = _balance(instants)
|
|
315
|
+
if durations.get("CashFlowsFromUsedInOperatingActivities") is not None:
|
|
316
|
+
cashflow[r["end"]] = _cashflow(durations)
|
|
317
|
+
if len(income) < 2:
|
|
318
|
+
# Banks and insurers file a different layout (interest earned, ...): leave them to Yahoo.
|
|
319
|
+
raise RuntimeError(f"NSE filings for {symbol} have no readable income statement")
|
|
320
|
+
_restate_eps(income, shares)
|
|
321
|
+
return _frame(income), _frame(balance), _frame(cashflow)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _share_count(v: dict) -> Optional[float]:
|
|
325
|
+
paid_up = v.get("PaidUpValueOfEquityShareCapital") or v.get("EquityShareCapital")
|
|
326
|
+
face = v.get("FaceValueOfEquityShareCapital")
|
|
327
|
+
return paid_up / face if paid_up and face else None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _restate_eps(income: dict[date, dict], shares: dict[date, float]) -> None:
|
|
331
|
+
"""Filings report EPS on the share count of their day; a later bonus or
|
|
332
|
+
split would read as an earnings collapse. Restate each period's EPS onto
|
|
333
|
+
the latest share count (as Yahoo does)."""
|
|
334
|
+
if not shares:
|
|
335
|
+
return
|
|
336
|
+
latest = shares[max(shares)]
|
|
337
|
+
for end, row in income.items():
|
|
338
|
+
count = shares.get(end)
|
|
339
|
+
if not count or abs(count / latest - 1) < 0.01:
|
|
340
|
+
continue
|
|
341
|
+
for key in ("Diluted EPS", "Basic EPS"):
|
|
342
|
+
if row.get(key) is not None:
|
|
343
|
+
row[key] = round(row[key] * count / latest, 2)
|