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,202 @@
|
|
|
1
|
+
"""Central-bank policy rates for about 40 economies (BIS, no key).
|
|
2
|
+
|
|
3
|
+
From the BIS daily policy-rate series: today's rate, when it last changed
|
|
4
|
+
and from what, the change over a year, and the real rate against the IMF's
|
|
5
|
+
inflation projection for this year (marketmyna/services/country_macro.py). Kept
|
|
6
|
+
on disk for 12 hours; a failed refresh serves the saved copy marked stale.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from datetime import date, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
from marketmyna.core.home import app_home
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
URL = "https://stats.bis.org/api/v1/data/WS_CBPOL/D./all"
|
|
28
|
+
REFRESH_SECONDS = 12 * 3600
|
|
29
|
+
|
|
30
|
+
# BIS area -> (IMF code, central bank)
|
|
31
|
+
AREAS = {
|
|
32
|
+
"US": ("USA", "Federal Reserve"), "XM": ("", "European Central Bank"), "GB": ("GBR", "Bank of England"),
|
|
33
|
+
"JP": ("JPN", "Bank of Japan"), "CN": ("CHN", "People's Bank of China"), "IN": ("IND", "Reserve Bank of India"),
|
|
34
|
+
"CA": ("CAN", "Bank of Canada"), "AU": ("AUS", "Reserve Bank of Australia"), "CH": ("CHE", "Swiss National Bank"),
|
|
35
|
+
"KR": ("KOR", "Bank of Korea"), "BR": ("BRA", "Central Bank of Brazil"), "MX": ("MEX", "Bank of Mexico"),
|
|
36
|
+
"ID": ("IDN", "Bank Indonesia"), "ZA": ("ZAF", "South African Reserve Bank"), "TR": ("TUR", "Central Bank of Türkiye"),
|
|
37
|
+
"RU": ("RUS", "Bank of Russia"), "SA": ("SAU", "Saudi Central Bank"), "HK": ("HKG", "Hong Kong Monetary Authority"),
|
|
38
|
+
"SE": ("SWE", "Riksbank"), "NO": ("NOR", "Norges Bank"), "DK": ("DNK", "Danmarks Nationalbank"),
|
|
39
|
+
"NZ": ("NZL", "Reserve Bank of New Zealand"), "PL": ("POL", "National Bank of Poland"),
|
|
40
|
+
"CZ": ("CZE", "Czech National Bank"), "HU": ("HUN", "Magyar Nemzeti Bank"), "RO": ("ROU", "National Bank of Romania"),
|
|
41
|
+
"IL": ("ISR", "Bank of Israel"), "TH": ("THA", "Bank of Thailand"), "MY": ("MYS", "Bank Negara Malaysia"),
|
|
42
|
+
"PH": ("PHL", "Bangko Sentral ng Pilipinas"), "CL": ("CHL", "Central Bank of Chile"),
|
|
43
|
+
"CO": ("COL", "Central Bank of Colombia"), "PE": ("PER", "Central Reserve Bank of Peru"),
|
|
44
|
+
"AR": ("ARG", "Central Bank of Argentina"), "IS": ("ISL", "Central Bank of Iceland"),
|
|
45
|
+
"KW": ("KWT", "Central Bank of Kuwait"), "MA": ("MAR", "Bank Al-Maghrib"), "RS": ("SRB", "National Bank of Serbia"),
|
|
46
|
+
"MK": ("MKD", "National Bank of North Macedonia"),
|
|
47
|
+
}
|
|
48
|
+
FIRST = ["IN", "US", "XM", "GB", "JP", "CN"] # shown first; the rest by name
|
|
49
|
+
|
|
50
|
+
Fetch = Callable[[str], str]
|
|
51
|
+
|
|
52
|
+
_lock = threading.Lock()
|
|
53
|
+
_state: dict[str, object] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dir() -> Path:
|
|
57
|
+
path = app_home() / "bis"
|
|
58
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
return path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get(start: str) -> str:
|
|
63
|
+
r = requests.get(URL, params={"startPeriod": start, "format": "csv"}, timeout=60)
|
|
64
|
+
r.raise_for_status()
|
|
65
|
+
return r.text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse(text: str) -> dict[str, dict]:
|
|
69
|
+
"""{area: {"name": ..., "points": [(date, rate), ...]}} oldest first, gaps dropped."""
|
|
70
|
+
out: dict[str, dict] = {}
|
|
71
|
+
for row in csv.DictReader(io.StringIO(text)):
|
|
72
|
+
try:
|
|
73
|
+
value = float(row["OBS_VALUE"])
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
continue
|
|
76
|
+
if value != value: # NaN
|
|
77
|
+
continue
|
|
78
|
+
area = row["REF_AREA"]
|
|
79
|
+
title = (row.get("TITLE") or "").split(" - ")
|
|
80
|
+
item = out.setdefault(area, {"name": title[1].strip() if len(title) > 1 else area, "points": []})
|
|
81
|
+
item["points"].append((row["TIME_PERIOD"], value))
|
|
82
|
+
for item in out.values():
|
|
83
|
+
item["points"].sort()
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _data(fetch: Fetch, today: date) -> tuple[dict, bool]:
|
|
88
|
+
with _lock:
|
|
89
|
+
data = _state.get("data")
|
|
90
|
+
if data is None:
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads((_dir() / "policy-rates.json").read_text(encoding="utf-8"))
|
|
93
|
+
except (OSError, ValueError):
|
|
94
|
+
data = None
|
|
95
|
+
if data is not None and time.time() - data.get("fetched", 0) < REFRESH_SECONDS:
|
|
96
|
+
with _lock:
|
|
97
|
+
_state["data"] = data
|
|
98
|
+
return data, False
|
|
99
|
+
try:
|
|
100
|
+
areas = parse(fetch((today - timedelta(days=730)).isoformat()))
|
|
101
|
+
if not areas:
|
|
102
|
+
raise ValueError("BIS returned no rates")
|
|
103
|
+
fresh = {"areas": areas, "fetched": time.time()}
|
|
104
|
+
except Exception as exc: # noqa: BLE001 - serve the saved copy
|
|
105
|
+
logger.warning("BIS policy rates failed: %s", exc)
|
|
106
|
+
if data is None:
|
|
107
|
+
raise
|
|
108
|
+
return data, True
|
|
109
|
+
try:
|
|
110
|
+
(_dir() / "policy-rates.json").write_text(json.dumps(fresh), encoding="utf-8")
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
logger.warning("Could not save policy rates: %s", exc)
|
|
113
|
+
with _lock:
|
|
114
|
+
_state["data"] = fresh
|
|
115
|
+
return fresh, False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _inflation(year: int) -> dict[str, float]:
|
|
119
|
+
"""IMF inflation projection for this year by IMF code, if already loaded (never blocks on the IMF)."""
|
|
120
|
+
try:
|
|
121
|
+
from . import country_macro
|
|
122
|
+
|
|
123
|
+
with country_macro._lock:
|
|
124
|
+
data = country_macro._state.get("data")
|
|
125
|
+
if data is None:
|
|
126
|
+
data = json.loads((country_macro._dir() / "weo.json").read_text(encoding="utf-8"))
|
|
127
|
+
series = (data.get("values") or {}).get("PCPIPCH") or {}
|
|
128
|
+
return {code: v[str(year)] for code, v in series.items() if v.get(str(year)) is not None}
|
|
129
|
+
except Exception: # noqa: BLE001 - the real rate is an extra
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _rbi_repo() -> Optional[tuple[str, float]]:
|
|
134
|
+
"""(date, repo rate) from the RBI page snapshot marketmyna/services/india_rates.py
|
|
135
|
+
keeps; the BIS series for India can lag the RBI by months."""
|
|
136
|
+
try:
|
|
137
|
+
from . import india_rates
|
|
138
|
+
|
|
139
|
+
path = india_rates._dir() / "rbi.json"
|
|
140
|
+
if not path.exists() or time.time() - path.stat().st_mtime > 86400:
|
|
141
|
+
india_rates.build() # refreshes the snapshot (it has its own cache and fallback)
|
|
142
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
143
|
+
repo = next(p["value"] for p in data.get("policy") or [] if p.get("key") == "repo")
|
|
144
|
+
return str(data.get("fetched_at") or "")[:10], float(repo)
|
|
145
|
+
except Exception: # noqa: BLE001 - BIS alone is fine
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def with_rbi(points: list, rbi: Optional[tuple[str, float]]) -> list:
|
|
150
|
+
"""Append the RBI's own reading when it is newer than the BIS series."""
|
|
151
|
+
if rbi and rbi[0] and (not points or rbi[0] > points[-1][0]):
|
|
152
|
+
return [*points, rbi]
|
|
153
|
+
return points
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def summarise(area: str, name: str, points: list, today: date, inflation: dict[str, float]) -> Optional[dict]:
|
|
157
|
+
if not points:
|
|
158
|
+
return None
|
|
159
|
+
as_of, rate = points[-1]
|
|
160
|
+
changed, previous = None, None
|
|
161
|
+
for when, value in reversed(points):
|
|
162
|
+
if value != rate:
|
|
163
|
+
previous = value
|
|
164
|
+
break
|
|
165
|
+
changed = when
|
|
166
|
+
year_ago = [v for d, v in points if d <= (today - timedelta(days=365)).isoformat()]
|
|
167
|
+
imf, bank = AREAS.get(area, ("", ""))
|
|
168
|
+
infl = inflation.get(imf) if imf else None
|
|
169
|
+
monthly: dict[str, float] = {}
|
|
170
|
+
for when, value in points:
|
|
171
|
+
monthly[when[:7]] = value # month-end value
|
|
172
|
+
return {
|
|
173
|
+
"area": area, "name": name, "bank": bank, "rate": rate, "as_of": as_of,
|
|
174
|
+
"last_change": changed if previous is not None else None, "previous": previous,
|
|
175
|
+
"direction": None if previous is None else ("cut" if rate < previous else "hike"),
|
|
176
|
+
"change_1y": round(rate - year_ago[-1], 3) if year_ago else None,
|
|
177
|
+
"inflation": infl, "real_rate": round(rate - infl, 2) if infl is not None else None,
|
|
178
|
+
"spark": [{"month": m, "rate": v} for m, v in monthly.items()],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build(fetch: Fetch = _get, today: Optional[date] = None) -> dict:
|
|
183
|
+
today = today or date.today()
|
|
184
|
+
data, stale = _data(fetch, today)
|
|
185
|
+
inflation = _inflation(today.year)
|
|
186
|
+
rbi = _rbi_repo()
|
|
187
|
+
rows = []
|
|
188
|
+
for area, item in (data.get("areas") or {}).items():
|
|
189
|
+
points = [tuple(p) for p in item["points"]]
|
|
190
|
+
bis_last = points[-1] if points else None
|
|
191
|
+
if area == "IN":
|
|
192
|
+
points = with_rbi(points, rbi)
|
|
193
|
+
if r := summarise(area, item["name"], points, today, inflation):
|
|
194
|
+
if bis_last and points[-1] != bis_last and points[-1][1] != bis_last[1]:
|
|
195
|
+
# moved between the last BIS day and the RBI reading: exact date unknown
|
|
196
|
+
r["last_change"], r["changed_between"] = None, [bis_last[0], points[-1][0]]
|
|
197
|
+
rows.append(r)
|
|
198
|
+
rows.sort(key=lambda r: (FIRST.index(r["area"]) if r["area"] in FIRST else len(FIRST), r["name"]))
|
|
199
|
+
return {"rows": rows, "stale": stale,
|
|
200
|
+
"as_of": time.strftime("%Y-%m-%d", time.localtime(data.get("fetched", time.time()))),
|
|
201
|
+
"source": "BIS central bank policy rates (India: RBI's own page when newer); real rate uses the IMF inflation projection for "
|
|
202
|
+
f"{today.year}"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Commodities snapshot: front-month futures from Yahoo (no key) with 1-day,
|
|
2
|
+
1-month and 1-year change and a 3-month sparkline, plus gold and silver in
|
|
3
|
+
rupees for Indian users. Covers the useful core of OpenBB's commodity
|
|
4
|
+
endpoints. CFTC positioning (cot.py) and EIA oil stocks (eia_stocks.py) are
|
|
5
|
+
separate endpoints the card loads after the prices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
CACHE_SECONDS = 15 * 60
|
|
20
|
+
TROY_OZ_G = 31.1034768
|
|
21
|
+
|
|
22
|
+
# ticker, label, group, unit, note
|
|
23
|
+
ITEMS = [
|
|
24
|
+
("GC=F", "Gold", "metals", "$/oz", "Safe-haven; tends to rise when real interest rates fall"),
|
|
25
|
+
("SI=F", "Silver", "metals", "$/oz", "Part precious, part industrial metal"),
|
|
26
|
+
("HG=F", "Copper", "metals", "$/lb", "Industrial demand gauge (\"Dr Copper\")"),
|
|
27
|
+
("CL=F", "WTI crude", "energy", "$/bbl", "US oil benchmark"),
|
|
28
|
+
("BZ=F", "Brent crude", "energy", "$/bbl", "Global oil benchmark; India's import bill moves with it"),
|
|
29
|
+
("NG=F", "Natural gas", "energy", "$/MMBtu", "US Henry Hub gas"),
|
|
30
|
+
("ZW=F", "Wheat", "agri", "¢/bu", "CBOT wheat"),
|
|
31
|
+
("ZC=F", "Corn", "agri", "¢/bu", "CBOT corn"),
|
|
32
|
+
]
|
|
33
|
+
FX = "USDINR=X"
|
|
34
|
+
|
|
35
|
+
Fetch = Callable[[list[str]], dict[str, pd.Series]]
|
|
36
|
+
|
|
37
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
38
|
+
_raw_cache: dict[str, tuple[float, dict]] = {}
|
|
39
|
+
_lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
|
|
43
|
+
import yfinance as yf
|
|
44
|
+
|
|
45
|
+
from ..data import ecb_fx
|
|
46
|
+
from ..utils.yf_lock import YF_DOWNLOAD_LOCK
|
|
47
|
+
|
|
48
|
+
def download() -> dict[str, pd.Series]:
|
|
49
|
+
# 2y, not 1y: a 1-year window ending at a bar dated yesterday has no bar a
|
|
50
|
+
# full year back, so the 1-year change would come out empty.
|
|
51
|
+
with YF_DOWNLOAD_LOCK:
|
|
52
|
+
df = yf.download(tickers, period="2y", interval="1d", progress=False, auto_adjust=False, threads=True)
|
|
53
|
+
closes = df["Close"] if isinstance(df.columns, pd.MultiIndex) else df[["Close"]].rename(columns={"Close": tickers[0]})
|
|
54
|
+
return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
|
|
55
|
+
|
|
56
|
+
# Currency pairs Yahoo misses come from the ECB's daily reference rates
|
|
57
|
+
return ecb_fx.guarded(download, tickers)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _change(s: pd.Series, days: int) -> Optional[float]:
|
|
61
|
+
when = s.index[-1] - pd.Timedelta(days=days)
|
|
62
|
+
past = s[s.index <= when]
|
|
63
|
+
return float(s.iloc[-1] / past.iloc[-1] - 1) * 100 if len(past) and past.iloc[-1] else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _row(key: str, label: str, group: str, unit: str, note: str, s: pd.Series) -> dict:
|
|
67
|
+
spark = s[s.index >= s.index[-1] - pd.Timedelta(days=92)]
|
|
68
|
+
return {
|
|
69
|
+
"key": key, "label": label, "group": group, "unit": unit, "note": note,
|
|
70
|
+
"price": float(s.iloc[-1]),
|
|
71
|
+
"as_of": str(pd.Timestamp(s.index[-1]).date()),
|
|
72
|
+
"change_1d": float(s.iloc[-1] / s.iloc[-2] - 1) * 100 if len(s) > 1 and s.iloc[-2] else None,
|
|
73
|
+
"change_1m": _change(s, 30),
|
|
74
|
+
"change_1y": _change(s, 365),
|
|
75
|
+
"spark": [round(float(v), 4) for v in spark.to_numpy()],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def tickers() -> list[str]:
|
|
80
|
+
return [t for t, *_ in ITEMS] + [FX]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build(fetch: Fetch = _yahoo, use_cache: bool = True, live: Optional[dict] = None) -> dict:
|
|
84
|
+
"""`live`: ticker -> {price, day} from live_market.latest, moving each
|
|
85
|
+
contract's latest price (and the rupee prices with it); the history
|
|
86
|
+
behind it is cached CACHE_SECONDS."""
|
|
87
|
+
from .live_market import extend_all
|
|
88
|
+
|
|
89
|
+
if use_cache and not live:
|
|
90
|
+
with _lock:
|
|
91
|
+
hit = _cache.get("all")
|
|
92
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
93
|
+
return hit[1]
|
|
94
|
+
|
|
95
|
+
data = None
|
|
96
|
+
if use_cache:
|
|
97
|
+
with _lock:
|
|
98
|
+
hit = _raw_cache.get("all")
|
|
99
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
100
|
+
data = hit[1]
|
|
101
|
+
if data is None:
|
|
102
|
+
try:
|
|
103
|
+
data = fetch(tickers())
|
|
104
|
+
except Exception as exc: # network / Yahoo hiccup
|
|
105
|
+
logger.warning("Commodity fetch failed: %s", exc)
|
|
106
|
+
data = {}
|
|
107
|
+
if use_cache and data:
|
|
108
|
+
with _lock:
|
|
109
|
+
_raw_cache["all"] = (time.time(), data)
|
|
110
|
+
if live:
|
|
111
|
+
data = extend_all(data, live)
|
|
112
|
+
|
|
113
|
+
rows = [_row(t, label, group, unit, note, data[t]) for t, label, group, unit, note in ITEMS
|
|
114
|
+
if t in data and len(data[t]) > 1]
|
|
115
|
+
|
|
116
|
+
# Gold / silver in rupees: international price x USDINR, before import duty and GST,
|
|
117
|
+
# so MCX / jeweller prices run a few percent higher.
|
|
118
|
+
fx = data.get(FX)
|
|
119
|
+
if fx is not None and len(fx) > 1:
|
|
120
|
+
for t, label, unit, grams in (("GC=F", "Gold (₹)", "₹/10g", 10), ("SI=F", "Silver (₹)", "₹/kg", 1000)):
|
|
121
|
+
if t in data:
|
|
122
|
+
usd = data[t]
|
|
123
|
+
joined = pd.concat([usd, fx], axis=1, keys=["p", "fx"]).ffill().dropna()
|
|
124
|
+
inr = joined["p"] * joined["fx"] / TROY_OZ_G * grams
|
|
125
|
+
if len(inr) > 1:
|
|
126
|
+
rows.append(_row(f"{t}:INR", label, "india", unit,
|
|
127
|
+
"International price in rupees, before import duty and GST", inr))
|
|
128
|
+
|
|
129
|
+
out = {"items": rows, "updated": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
130
|
+
"usd_inr": float(fx.iloc[-1]) if fx is not None and len(fx) else None}
|
|
131
|
+
if use_cache and rows and not live:
|
|
132
|
+
with _lock:
|
|
133
|
+
_cache["all"] = (time.time(), out)
|
|
134
|
+
return out
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Side-by-side comparison of 2-5 instruments, and automatic peer discovery.
|
|
2
|
+
|
|
3
|
+
Mirrors OpenBB's equity "compare" / "peers" commands with free data: prices
|
|
4
|
+
from Yahoo (adjusted, so dividends are included), fundamentals from Yahoo's
|
|
5
|
+
quote summary, peers from our own listings (same market and sector, nearest in
|
|
6
|
+
size) confirmed against Yahoo's finer "industry" label.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from marketmyna.core.home import app_home
|
|
25
|
+
|
|
26
|
+
from ..core.numbers import finite as _f
|
|
27
|
+
from ..data.yahoo_price import info_price
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
MAX_SYMBOLS = 5
|
|
32
|
+
PERIODS = {"6mo": 182, "1y": 365, "3y": 1095, "5y": 1826}
|
|
33
|
+
RETURN_WINDOWS = [("1M", 30), ("3M", 91), ("6M", 182), ("1Y", 365), ("3Y", 1095), ("5Y", 1826)]
|
|
34
|
+
INFO_TTL = 6 * 3600
|
|
35
|
+
|
|
36
|
+
# label, yahoo key, kind, higher is better (None = no ranking), group
|
|
37
|
+
METRICS = [
|
|
38
|
+
("Market cap", "marketCap", "money", None, "Size & valuation"),
|
|
39
|
+
("P/E (trailing)", "trailingPE", "ratio", False, "Size & valuation"),
|
|
40
|
+
("P/E (forward)", "forwardPE", "ratio", False, "Size & valuation"),
|
|
41
|
+
("PEG", "pegRatio", "ratio", False, "Size & valuation"),
|
|
42
|
+
("Price / book", "priceToBook", "ratio", False, "Size & valuation"),
|
|
43
|
+
("Price / sales", "priceToSalesTrailing12Months", "ratio", False, "Size & valuation"),
|
|
44
|
+
("EV / EBITDA", "enterpriseToEbitda", "ratio", False, "Size & valuation"),
|
|
45
|
+
("Revenue growth", "revenueGrowth", "pct", True, "Growth & profitability"),
|
|
46
|
+
("Earnings growth", "earningsGrowth", "pct", True, "Growth & profitability"),
|
|
47
|
+
("Gross margin", "grossMargins", "pct", True, "Growth & profitability"),
|
|
48
|
+
("Operating margin", "operatingMargins", "pct", True, "Growth & profitability"),
|
|
49
|
+
("Profit margin", "profitMargins", "pct", True, "Growth & profitability"),
|
|
50
|
+
("Return on equity", "returnOnEquity", "pct", True, "Growth & profitability"),
|
|
51
|
+
("Return on assets", "returnOnAssets", "pct", True, "Growth & profitability"),
|
|
52
|
+
("Debt / equity", "debtToEquity", "ratio", False, "Balance sheet & income"),
|
|
53
|
+
("Current ratio", "currentRatio", "ratio", True, "Balance sheet & income"),
|
|
54
|
+
("Free cash flow", "freeCashflow", "money", True, "Balance sheet & income"),
|
|
55
|
+
("Dividend yield", "dividendYield", "pct_raw", True, "Balance sheet & income"),
|
|
56
|
+
("Payout ratio", "payoutRatio", "pct", None, "Balance sheet & income"),
|
|
57
|
+
("Analyst target upside", None, "pct_raw", True, "Analysts"),
|
|
58
|
+
("Analyst rating (1 buy - 5 sell)", "recommendationMean", "ratio", False, "Analysts"),
|
|
59
|
+
("Analysts", "numberOfAnalystOpinions", "count", None, "Analysts"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
InfoFetch = Callable[[str], dict]
|
|
63
|
+
PriceFetch = Callable[[list[str], str], dict[str, pd.Series]]
|
|
64
|
+
|
|
65
|
+
_info_cache: dict[str, tuple[float, dict]] = {}
|
|
66
|
+
_info_lock = threading.Lock()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _info_file(yf_symbol: str) -> Path:
|
|
70
|
+
base = Path(os.environ.get("YAHOO_INFO_CACHE_DIR") or app_home() / "cache" / "yahoo_info")
|
|
71
|
+
return base / (hashlib.sha256(yf_symbol.encode()).hexdigest()[:24] + ".json")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _disk_info(yf_symbol: str) -> Optional[tuple[float, dict]]:
|
|
75
|
+
"""The saved copy, so an API restart or deploy doesn't make every screen
|
|
76
|
+
and Compare page fetch ~100 stocks from Yahoo again."""
|
|
77
|
+
try:
|
|
78
|
+
saved = json.loads(_info_file(yf_symbol).read_text(encoding="utf-8"))
|
|
79
|
+
return float(saved["at"]), saved["info"]
|
|
80
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _save_info(yf_symbol: str, at: float, info: dict) -> None:
|
|
85
|
+
path = _info_file(yf_symbol)
|
|
86
|
+
try:
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
tmp = path.with_suffix(".tmp")
|
|
89
|
+
tmp.write_text(json.dumps({"at": at, "info": info}, default=str), encoding="utf-8")
|
|
90
|
+
tmp.replace(path)
|
|
91
|
+
except OSError as exc:
|
|
92
|
+
logger.debug("Could not save Yahoo info for %s: %s", yf_symbol, exc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _yahoo_info(yf_symbol: str) -> dict:
|
|
96
|
+
with _info_lock:
|
|
97
|
+
hit = _info_cache.get(yf_symbol)
|
|
98
|
+
if not hit:
|
|
99
|
+
hit = _disk_info(yf_symbol)
|
|
100
|
+
if hit:
|
|
101
|
+
with _info_lock:
|
|
102
|
+
_info_cache[yf_symbol] = hit
|
|
103
|
+
if hit and time.time() - hit[0] < INFO_TTL:
|
|
104
|
+
return hit[1]
|
|
105
|
+
import yfinance as yf
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
info = yf.Ticker(yf_symbol).info or {}
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
logger.info("Yahoo info %s failed: %s", yf_symbol, exc)
|
|
111
|
+
info = {}
|
|
112
|
+
if info: # never cache a failed lookup; Yahoo throttles in bursts and the next call usually works
|
|
113
|
+
now = time.time()
|
|
114
|
+
with _info_lock:
|
|
115
|
+
_info_cache[yf_symbol] = (now, info)
|
|
116
|
+
_save_info(yf_symbol, now, info)
|
|
117
|
+
return info
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _yahoo_prices(tickers: list[str], period: str) -> dict[str, pd.Series]:
|
|
121
|
+
import yfinance as yf
|
|
122
|
+
|
|
123
|
+
from ..utils.yf_lock import YF_DOWNLOAD_LOCK
|
|
124
|
+
|
|
125
|
+
with YF_DOWNLOAD_LOCK:
|
|
126
|
+
df = yf.download(tickers, period=period, interval="1d", progress=False, auto_adjust=True, threads=True)
|
|
127
|
+
if df is None or df.empty:
|
|
128
|
+
return {}
|
|
129
|
+
closes = df["Close"] if isinstance(df.columns, pd.MultiIndex) else df[["Close"]].rename(columns={"Close": tickers[0]})
|
|
130
|
+
return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _naive(s: pd.Series) -> pd.Series:
|
|
134
|
+
idx = pd.DatetimeIndex(s.index)
|
|
135
|
+
if idx.tz is not None:
|
|
136
|
+
idx = idx.tz_localize(None)
|
|
137
|
+
return pd.Series(s.to_numpy(dtype=float), index=idx.normalize())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _ret(s: pd.Series, days: int) -> Optional[float]:
|
|
141
|
+
if s.empty or (s.index[-1] - s.index[0]).days < days * 0.97:
|
|
142
|
+
return None
|
|
143
|
+
past = s[s.index <= s.index[-1] - pd.Timedelta(days=days)]
|
|
144
|
+
return float(s.iloc[-1] / past.iloc[-1] - 1) * 100 if len(past) else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _metric_value(info: dict, key: Optional[str], kind: str, label: str) -> Optional[float]:
|
|
148
|
+
if label == "Analyst target upside":
|
|
149
|
+
target, price = _f(info.get("targetMeanPrice")), info_price(info)
|
|
150
|
+
return (target / price - 1) * 100 if target and price else None
|
|
151
|
+
v = _f(info.get(key)) if key else None
|
|
152
|
+
if v is None:
|
|
153
|
+
return None
|
|
154
|
+
if key in ("grossMargins", "operatingMargins") and v == 0:
|
|
155
|
+
return None # Yahoo reports 0 for banks and insurers, where margins do not apply
|
|
156
|
+
if kind == "pct":
|
|
157
|
+
return v * 100
|
|
158
|
+
if key == "debtToEquity":
|
|
159
|
+
return v / 100 # Yahoo reports D/E in percent
|
|
160
|
+
return v
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def compare(items: list[tuple[str, str]], period: str = "1y",
|
|
164
|
+
info_fetch: InfoFetch = _yahoo_info, price_fetch: PriceFetch = _yahoo_prices) -> dict:
|
|
165
|
+
"""items: [(symbol, market)]. Raises ValueError on bad input."""
|
|
166
|
+
from .analysis_run import MARKET_INDICES, yahoo_symbol
|
|
167
|
+
|
|
168
|
+
items = list(dict.fromkeys((s.strip().upper(), m.strip().lower()) for s, m in items if s.strip()))
|
|
169
|
+
if not 2 <= len(items) <= MAX_SYMBOLS:
|
|
170
|
+
raise ValueError(f"Pick between 2 and {MAX_SYMBOLS} symbols")
|
|
171
|
+
if period not in PERIODS:
|
|
172
|
+
raise ValueError(f"period must be one of {', '.join(PERIODS)}")
|
|
173
|
+
|
|
174
|
+
ticker = {(s, m): yahoo_symbol(s, m) for s, m in items}
|
|
175
|
+
bench = {m: (MARKET_INDICES.get(m) or MARKET_INDICES["us"])[0] for _, m in items}
|
|
176
|
+
fetch_period = "5y" if period in ("3y", "5y") else "2y" # extra history so 1Y/3Y returns resolve
|
|
177
|
+
with ThreadPoolExecutor(max_workers=6) as pool:
|
|
178
|
+
infos_f = {k: pool.submit(info_fetch, t) for k, t in ticker.items() if k[1] != "crypto"}
|
|
179
|
+
prices = price_fetch(list(dict.fromkeys([*ticker.values(), *(b[0] for b in bench.values())])), fetch_period)
|
|
180
|
+
infos = {k: f.result() for k, f in infos_f.items()}
|
|
181
|
+
|
|
182
|
+
out_items, series, warnings = [], {}, []
|
|
183
|
+
for key in items:
|
|
184
|
+
s, m = key
|
|
185
|
+
info = infos.get(key, {})
|
|
186
|
+
raw = prices.get(ticker[key])
|
|
187
|
+
if raw is None or len(raw) < 20:
|
|
188
|
+
warnings.append(f"{s}: no price history, left out of the chart and risk numbers.")
|
|
189
|
+
else:
|
|
190
|
+
series[key] = _naive(raw)
|
|
191
|
+
out_items.append({
|
|
192
|
+
"symbol": s, "market": m, "yahoo": ticker[key],
|
|
193
|
+
"name": info.get("shortName") or info.get("longName") or s,
|
|
194
|
+
"currency": info.get("currency") or ("USD" if m == "crypto" else None),
|
|
195
|
+
"sector": info.get("sector"), "industry": info.get("industry"),
|
|
196
|
+
"price": info_price(info) or (float(series[key].iloc[-1]) if key in series else None),
|
|
197
|
+
})
|
|
198
|
+
currencies = {i["currency"] for i in out_items if i["currency"]}
|
|
199
|
+
if len(currencies) > 1:
|
|
200
|
+
warnings.append("Different currencies: returns are compared in each stock's own currency.")
|
|
201
|
+
|
|
202
|
+
start = pd.Timestamp.today().normalize() - pd.Timedelta(days=PERIODS[period])
|
|
203
|
+
chart: dict[str, dict[str, float]] = {}
|
|
204
|
+
risk, daily = {}, {}
|
|
205
|
+
for key, s in series.items():
|
|
206
|
+
label = key[0]
|
|
207
|
+
win = s[s.index >= start]
|
|
208
|
+
if len(win) < 2:
|
|
209
|
+
continue
|
|
210
|
+
for d, v in (win / win.iloc[0] * 100).items():
|
|
211
|
+
chart.setdefault(str(d.date()), {})[label] = round(float(v), 3)
|
|
212
|
+
r = win.pct_change().dropna()
|
|
213
|
+
daily[label] = r
|
|
214
|
+
curve = (1 + r).cumprod()
|
|
215
|
+
b = prices.get(bench[key[1]][0])
|
|
216
|
+
beta = None
|
|
217
|
+
if b is not None and len(r) > 30:
|
|
218
|
+
br = _naive(b).pct_change().dropna()
|
|
219
|
+
j = pd.concat([r, br], axis=1, join="inner").dropna()
|
|
220
|
+
if len(j) > 30 and j.iloc[:, 1].var() > 0:
|
|
221
|
+
beta = float(j.iloc[:, 0].cov(j.iloc[:, 1]) / j.iloc[:, 1].var())
|
|
222
|
+
days_per_year = 365 if key[1] == "crypto" else 252
|
|
223
|
+
vol = float(r.std() * math.sqrt(days_per_year))
|
|
224
|
+
years = max((win.index[-1] - win.index[0]).days / 365.25, 1e-9)
|
|
225
|
+
cagr = float((win.iloc[-1] / win.iloc[0]) ** (1 / years) - 1)
|
|
226
|
+
risk[label] = {
|
|
227
|
+
"volatility": vol * 100,
|
|
228
|
+
"max_drawdown": float((curve / curve.cummax() - 1).min()) * 100,
|
|
229
|
+
"beta": beta, "benchmark": bench[key[1]][1],
|
|
230
|
+
"return_period": float(win.iloc[-1] / win.iloc[0] - 1) * 100,
|
|
231
|
+
"sharpe_like": cagr / vol if vol else None,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
returns = [{"window": w, "values": {k[0]: _ret(s, d) for k, s in series.items()}} for w, d in RETURN_WINDOWS]
|
|
235
|
+
returns = [r for r in returns if any(v is not None for v in r["values"].values())]
|
|
236
|
+
|
|
237
|
+
corr = None
|
|
238
|
+
if len(daily) >= 2:
|
|
239
|
+
c = pd.DataFrame(daily).dropna().corr()
|
|
240
|
+
corr = {"symbols": list(c.columns), "matrix": [[_f(v) for v in row] for row in c.to_numpy()]}
|
|
241
|
+
|
|
242
|
+
metrics = []
|
|
243
|
+
for label, key, kind, higher, group in METRICS:
|
|
244
|
+
vals = {i["symbol"]: _metric_value(infos.get((i["symbol"], i["market"]), {}), key, kind, label) for i in out_items}
|
|
245
|
+
if all(v is None for v in vals.values()):
|
|
246
|
+
continue
|
|
247
|
+
best = None
|
|
248
|
+
ranked = {k: v for k, v in vals.items() if v is not None and not (label.startswith("P/") and v <= 0)}
|
|
249
|
+
if kind == "money" and len(currencies) > 1:
|
|
250
|
+
ranked = {} # amounts in different currencies are not comparable
|
|
251
|
+
if higher is not None and len(ranked) >= 2:
|
|
252
|
+
best = (max if higher else min)(ranked, key=ranked.get)
|
|
253
|
+
metrics.append({"label": label, "kind": "pct" if kind == "pct_raw" else kind, "group": group,
|
|
254
|
+
"values": vals, "best": best, "higher_better": higher})
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"period": period,
|
|
258
|
+
"items": out_items,
|
|
259
|
+
"chart": [{"time": d, **v} for d, v in sorted(chart.items())],
|
|
260
|
+
"returns": returns,
|
|
261
|
+
"risk": risk,
|
|
262
|
+
"correlation": corr,
|
|
263
|
+
"metrics": metrics,
|
|
264
|
+
"warnings": warnings,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def peers(symbol: str, market: str, limit: int = 6, info_fetch: InfoFetch = _yahoo_info,
|
|
269
|
+
rows: Optional[list[dict]] = None) -> dict:
|
|
270
|
+
"""Same-market stocks in the same sector, nearest in market cap, preferring Yahoo's same industry."""
|
|
271
|
+
from .analysis_run import yahoo_symbol
|
|
272
|
+
|
|
273
|
+
symbol, market = symbol.upper(), market.lower()
|
|
274
|
+
if rows is None:
|
|
275
|
+
from ..data.listings import get_store
|
|
276
|
+
|
|
277
|
+
rows = get_store().rows(market)
|
|
278
|
+
stocks = [r for r in rows if r.get("type") == "stock" and r.get("market_cap")]
|
|
279
|
+
me = next((r for r in stocks if r["symbol"].upper() == symbol), None)
|
|
280
|
+
my_info = info_fetch(yahoo_symbol(symbol, market))
|
|
281
|
+
industry, sector = my_info.get("industry"), my_info.get("sector")
|
|
282
|
+
cap = (me or {}).get("market_cap") or _f(my_info.get("marketCap"))
|
|
283
|
+
group_key = "category" if me and me.get("category") else "sector"
|
|
284
|
+
group_val = (me or {}).get(group_key)
|
|
285
|
+
if not cap or not group_val:
|
|
286
|
+
return {"symbol": symbol, "market": market, "peers": [], "basis": None,
|
|
287
|
+
"note": "Not enough sector or size data to find peers."}
|
|
288
|
+
|
|
289
|
+
pool_rows = [r for r in stocks if r.get(group_key) == group_val and r["symbol"].upper() != symbol]
|
|
290
|
+
pool_rows.sort(key=lambda r: abs(math.log(r["market_cap"] / cap)))
|
|
291
|
+
candidates = pool_rows[:24]
|
|
292
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
293
|
+
infos = list(pool.map(lambda r: info_fetch(yahoo_symbol(r["symbol"], market)), candidates))
|
|
294
|
+
same_ind = [(r, i) for r, i in zip(candidates, infos) if industry and i.get("industry") == industry]
|
|
295
|
+
if len(same_ind) >= 3:
|
|
296
|
+
chosen, basis = same_ind[:limit], f"Same industry: {industry}"
|
|
297
|
+
else:
|
|
298
|
+
same_sec = [(r, i) for r, i in zip(candidates, infos) if sector and i.get("sector") == sector]
|
|
299
|
+
chosen = (same_ind + [x for x in same_sec if x not in same_ind])[:limit] or list(zip(candidates, infos))[:limit]
|
|
300
|
+
basis = f"Same sector: {sector or group_val}, closest in size"
|
|
301
|
+
return {
|
|
302
|
+
"symbol": symbol, "market": market, "industry": industry, "sector": sector, "basis": basis,
|
|
303
|
+
"peers": [{"symbol": r["symbol"], "name": r.get("name"), "market_cap": r["market_cap"],
|
|
304
|
+
"industry": i.get("industry"), "price": r.get("price"),
|
|
305
|
+
"change_percent": r.get("change_percent"),
|
|
306
|
+
"pe": _f(i.get("trailingPE")), "currency": r.get("currency")} for r, i in chosen],
|
|
307
|
+
}
|