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,143 @@
|
|
|
1
|
+
"""Who owns a stock and who is trading it: insider / institution split, top
|
|
2
|
+
holders, recent insider buys and sells, and short interest (US).
|
|
3
|
+
|
|
4
|
+
Source: Yahoo (via yfinance), which mirrors SEC Form 4 / 13F / FINRA short
|
|
5
|
+
interest for US listings and exchange disclosures, more sparsely, elsewhere.
|
|
6
|
+
Covers OpenBB's equity.ownership and equity.shorts endpoints (Apache 2.0).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from datetime import date, datetime, timedelta
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from ..core.numbers import finite as _f
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CACHE_SECONDS = 6 * 3600
|
|
25
|
+
INSIDER_DAYS = 180
|
|
26
|
+
|
|
27
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
28
|
+
_lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _day(v) -> Optional[str]:
|
|
32
|
+
if v is None or (isinstance(v, float) and math.isnan(v)):
|
|
33
|
+
return None
|
|
34
|
+
if isinstance(v, (int, float)):
|
|
35
|
+
return datetime.fromtimestamp(v).date().isoformat()
|
|
36
|
+
try:
|
|
37
|
+
return pd.Timestamp(v).date().isoformat()
|
|
38
|
+
except (ValueError, TypeError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def classify(text: str, transaction: str = "") -> str:
|
|
43
|
+
"""buy | sell | other, from Yahoo's free-text insider row."""
|
|
44
|
+
t = f"{transaction} {text}".lower()
|
|
45
|
+
if "sale" in t or "sell" in t or "sold" in t:
|
|
46
|
+
return "sell"
|
|
47
|
+
if "purchase" in t or "buy" in t or "bought" in t:
|
|
48
|
+
return "buy"
|
|
49
|
+
return "other"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _holders(df) -> list[dict]:
|
|
53
|
+
if df is None or getattr(df, "empty", True):
|
|
54
|
+
return []
|
|
55
|
+
out = []
|
|
56
|
+
for _, r in df.head(10).iterrows():
|
|
57
|
+
out.append({"holder": str(r.get("Holder")), "pct_held": _f(r.get("pctHeld")), "shares": _f(r.get("Shares")),
|
|
58
|
+
"value": _f(r.get("Value")), "pct_change": _f(r.get("pctChange")), "reported": _day(r.get("Date Reported"))})
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def insider_activity(df, today: Optional[date] = None, days: int = INSIDER_DAYS) -> dict:
|
|
63
|
+
today = today or date.today()
|
|
64
|
+
since = today - timedelta(days=days)
|
|
65
|
+
rows = []
|
|
66
|
+
if df is not None and not getattr(df, "empty", True):
|
|
67
|
+
for _, r in df.iterrows():
|
|
68
|
+
when = _day(r.get("Start Date"))
|
|
69
|
+
if not when or date.fromisoformat(when) < since:
|
|
70
|
+
continue
|
|
71
|
+
rows.append({"date": when, "insider": str(r.get("Insider") or ""), "position": str(r.get("Position") or ""),
|
|
72
|
+
"side": classify(str(r.get("Text") or ""), str(r.get("Transaction") or "")),
|
|
73
|
+
"shares": _f(r.get("Shares")), "value": _f(r.get("Value")), "text": str(r.get("Text") or "")})
|
|
74
|
+
rows.sort(key=lambda x: x["date"], reverse=True)
|
|
75
|
+
total = lambda side, k: sum((x[k] or 0) for x in rows if x["side"] == side) # noqa: E731
|
|
76
|
+
buys, sells = sum(x["side"] == "buy" for x in rows), sum(x["side"] == "sell" for x in rows)
|
|
77
|
+
bias = None
|
|
78
|
+
if buys or sells:
|
|
79
|
+
bias = "net buying" if total("buy", "value") > total("sell", "value") else "net selling"
|
|
80
|
+
return {"days": days, "buys": buys, "sells": sells, "buy_value": total("buy", "value"),
|
|
81
|
+
"sell_value": total("sell", "value"), "bias": bias, "recent": rows[:12]}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def short_interest(info: dict) -> Optional[dict]:
|
|
85
|
+
shares = _f(info.get("sharesShort"))
|
|
86
|
+
if shares is None:
|
|
87
|
+
return None
|
|
88
|
+
prior = _f(info.get("sharesShortPriorMonth"))
|
|
89
|
+
return {"shares": shares, "prior_month": prior,
|
|
90
|
+
"change_pct": (shares / prior - 1) * 100 if prior else None,
|
|
91
|
+
"pct_of_float": (_f(info.get("shortPercentOfFloat")) or 0) * 100 or None,
|
|
92
|
+
"days_to_cover": _f(info.get("shortRatio")), "as_of": _day(info.get("dateShortInterest"))}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _deals(symbol: str) -> Optional[dict]:
|
|
96
|
+
"""NSE bulk / block deals in the stock over the last 90 days."""
|
|
97
|
+
try:
|
|
98
|
+
from ..data.nse_deals import recent
|
|
99
|
+
|
|
100
|
+
return recent(90, symbol)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
logger.info("NSE deals for %s unavailable: %s", symbol, exc)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build(symbol: str, market: str, ticker=None) -> dict:
|
|
107
|
+
from .analysis_run import yahoo_symbol
|
|
108
|
+
|
|
109
|
+
yf_symbol = yahoo_symbol(symbol, market)
|
|
110
|
+
live = ticker is None
|
|
111
|
+
if live:
|
|
112
|
+
with _lock:
|
|
113
|
+
hit = _cache.get(yf_symbol)
|
|
114
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
115
|
+
return hit[1]
|
|
116
|
+
import yfinance as yf
|
|
117
|
+
|
|
118
|
+
ticker = yf.Ticker(yf_symbol)
|
|
119
|
+
|
|
120
|
+
def safe(attr):
|
|
121
|
+
try:
|
|
122
|
+
return getattr(ticker, attr)
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
logger.info("%s %s unavailable: %s", yf_symbol, attr, exc)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
info = safe("info") or {}
|
|
128
|
+
out = {
|
|
129
|
+
"symbol": symbol, "market": market,
|
|
130
|
+
"breakdown": {"insiders_pct": (_f(info.get("heldPercentInsiders")) or 0) * 100 or None,
|
|
131
|
+
"institutions_pct": (_f(info.get("heldPercentInstitutions")) or 0) * 100 or None,
|
|
132
|
+
"float_shares": _f(info.get("floatShares")),
|
|
133
|
+
"shares_outstanding": _f(info.get("sharesOutstanding"))},
|
|
134
|
+
"institutions": _holders(safe("institutional_holders")),
|
|
135
|
+
"funds": _holders(safe("mutualfund_holders")),
|
|
136
|
+
"insiders": insider_activity(safe("insider_transactions")),
|
|
137
|
+
"short": short_interest(info),
|
|
138
|
+
"deals": _deals(symbol) if market == "india" else None,
|
|
139
|
+
}
|
|
140
|
+
if live:
|
|
141
|
+
with _lock:
|
|
142
|
+
_cache[yf_symbol] = (time.time(), out)
|
|
143
|
+
return out
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Accounting quality scores from a company's last three annual statements,
|
|
2
|
+
rebuilt from the published papers:
|
|
3
|
+
|
|
4
|
+
- Piotroski F-score (Piotroski 2000): nine pass / fail tests on profitability,
|
|
5
|
+
leverage / liquidity and efficiency, latest year against the one before.
|
|
6
|
+
- Altman Z-score (Altman 1968) for manufacturers, which needs the market
|
|
7
|
+
value of equity; Z'' (Altman 1995) for everyone else, on book equity.
|
|
8
|
+
Banks and insurers are skipped by all three scores: lending shows up as
|
|
9
|
+
operating cash flow, and they have no gross margin or current ratio.
|
|
10
|
+
- Beneish M-score (Beneish 1999, 8 ratios): how far the statements look like
|
|
11
|
+
those of companies later found to have overstated earnings. Above -1.78 is
|
|
12
|
+
the paper's flag. A ratio that cannot be worked out is set to 1 (no change)
|
|
13
|
+
and listed.
|
|
14
|
+
|
|
15
|
+
Input is financials.py's {key: [value per year, newest first]} for the
|
|
16
|
+
income statement, balance sheet and cash flow. The scores describe the
|
|
17
|
+
filings; they are not a rating or advice.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ..core.numbers import ratio as _div
|
|
25
|
+
|
|
26
|
+
# Sectors scored with Altman's original model (built on listed manufacturers,
|
|
27
|
+
# needs market value); the rest (services, real estate, utilities, telecom,
|
|
28
|
+
# media) get Z'' on book equity
|
|
29
|
+
MANUFACTURING = {"Industrials", "Basic Materials", "Consumer Cyclical", "Consumer Defensive", "Energy",
|
|
30
|
+
"Healthcare", "Technology"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _v(series: dict, key: str, i: int) -> Optional[float]:
|
|
34
|
+
vals = series.get(key) or []
|
|
35
|
+
return vals[i] if i < len(vals) else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _r(x: Optional[float], digits: int = 3) -> Optional[float]:
|
|
39
|
+
return round(x, digits) if x is not None else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------ Piotroski
|
|
43
|
+
|
|
44
|
+
def piotroski(inc: dict, bal: dict, cf: dict, financial: bool = False) -> Optional[dict]:
|
|
45
|
+
if financial:
|
|
46
|
+
return None
|
|
47
|
+
ni0, ni1 = _v(inc, "net_income", 0), _v(inc, "net_income", 1)
|
|
48
|
+
ta0, ta1, ta2 = (_v(bal, "total_assets", i) for i in range(3))
|
|
49
|
+
if ni0 is None or ta0 is None or ta1 is None:
|
|
50
|
+
return None
|
|
51
|
+
cfo = _v(cf, "operating_cf", 0)
|
|
52
|
+
# ROA on assets at the start of the year (the paper's definition)
|
|
53
|
+
roa0 = _div(ni0, ta1)
|
|
54
|
+
roa1 = _div(ni1, ta2 if ta2 is not None else ta1)
|
|
55
|
+
|
|
56
|
+
def ratio(num_key: str, den_key: str, i: int):
|
|
57
|
+
return _div(_v(bal, num_key, i), _v(bal, den_key, i))
|
|
58
|
+
|
|
59
|
+
lev0, lev1 = ratio("total_debt", "total_assets", 0), ratio("total_debt", "total_assets", 1)
|
|
60
|
+
cr0, cr1 = ratio("current_assets", "current_liabilities", 0), ratio("current_assets", "current_liabilities", 1)
|
|
61
|
+
sh0, sh1 = _v(bal, "shares", 0), _v(bal, "shares", 1)
|
|
62
|
+
gm0 = _div(_v(inc, "gross_profit", 0), _v(inc, "revenue", 0))
|
|
63
|
+
gm1 = _div(_v(inc, "gross_profit", 1), _v(inc, "revenue", 1))
|
|
64
|
+
if gm0 is not None and gm1 is not None and gm0 >= 0.999 and gm1 >= 0.999:
|
|
65
|
+
gm0 = gm1 = None # no cost of goods reported (services): margin untestable
|
|
66
|
+
at0 = _div(_v(inc, "revenue", 0), ta1)
|
|
67
|
+
at1 = _div(_v(inc, "revenue", 1), ta2 if ta2 is not None else ta1)
|
|
68
|
+
|
|
69
|
+
def test(key, label, ok, detail):
|
|
70
|
+
return {"key": key, "label": label, "pass": ok, "detail": detail}
|
|
71
|
+
|
|
72
|
+
def pct(x):
|
|
73
|
+
return f"{x * 100:.1f}%" if x is not None else "n/a"
|
|
74
|
+
|
|
75
|
+
tests = [
|
|
76
|
+
test("roa", "Profitable (return on assets above 0)", None if roa0 is None else roa0 > 0, f"ROA {pct(roa0)}"),
|
|
77
|
+
test("cfo", "Cash from operations above 0", None if cfo is None else cfo > 0, "positive" if cfo and cfo > 0 else "zero or negative" if cfo is not None else "n/a"),
|
|
78
|
+
test("roa_up", "Return on assets improved", None if roa0 is None or roa1 is None else roa0 > roa1, f"{pct(roa1)} to {pct(roa0)}"),
|
|
79
|
+
test("accruals", "Cash from operations above net income", None if cfo is None else cfo > ni0, "earnings backed by cash" if cfo is not None and cfo > ni0 else "earnings ahead of cash" if cfo is not None else "n/a"),
|
|
80
|
+
test("leverage", "Debt to assets fell", None if lev0 is None or lev1 is None else lev0 <= lev1, f"{pct(lev1)} to {pct(lev0)}"),
|
|
81
|
+
test("liquidity", "Current ratio improved", None if cr0 is None or cr1 is None else cr0 > cr1,
|
|
82
|
+
f"{cr1:.2f} to {cr0:.2f}" if cr0 is not None and cr1 is not None else "n/a"),
|
|
83
|
+
test("dilution", "No new shares issued", None if sh0 is None or sh1 is None else sh0 <= sh1 * 1.001,
|
|
84
|
+
f"{(sh0 / sh1 - 1) * 100:+.1f}% shares" if sh0 and sh1 else "n/a"),
|
|
85
|
+
test("margin", "Gross margin improved", None if gm0 is None or gm1 is None else gm0 > gm1, f"{pct(gm1)} to {pct(gm0)}"),
|
|
86
|
+
test("turnover", "Asset turnover improved", None if at0 is None or at1 is None else at0 > at1,
|
|
87
|
+
f"{at1:.2f}x to {at0:.2f}x" if at0 is not None and at1 is not None else "n/a"),
|
|
88
|
+
]
|
|
89
|
+
tested = [t for t in tests if t["pass"] is not None]
|
|
90
|
+
if len(tested) < 5:
|
|
91
|
+
return None
|
|
92
|
+
score = sum(1 for t in tested if t["pass"])
|
|
93
|
+
if len(tested) == 9:
|
|
94
|
+
label = "strong" if score >= 7 else "weak" if score <= 3 else "average"
|
|
95
|
+
else:
|
|
96
|
+
share = score / len(tested)
|
|
97
|
+
label = "strong" if share >= 0.75 else "weak" if share <= 0.35 else "average"
|
|
98
|
+
return {"score": score, "out_of": len(tested), "label": label, "tests": tests}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------ Altman
|
|
102
|
+
|
|
103
|
+
def altman(inc: dict, bal: dict, sector: Optional[str], market_cap: Optional[float],
|
|
104
|
+
financial: bool = False) -> Optional[dict]:
|
|
105
|
+
if financial:
|
|
106
|
+
return None
|
|
107
|
+
ta = _v(bal, "total_assets", 0)
|
|
108
|
+
tl = _v(bal, "total_liabilities", 0)
|
|
109
|
+
ca, cl = _v(bal, "current_assets", 0), _v(bal, "current_liabilities", 0)
|
|
110
|
+
re_ = _v(bal, "retained_earnings", 0)
|
|
111
|
+
ebit = _v(inc, "operating_income", 0)
|
|
112
|
+
if ebit is None and _v(inc, "pretax_income", 0) is not None:
|
|
113
|
+
ebit = _v(inc, "pretax_income", 0) + (abs(_v(inc, "interest_expense", 0) or 0))
|
|
114
|
+
sales = _v(inc, "revenue", 0)
|
|
115
|
+
equity = _v(bal, "equity", 0)
|
|
116
|
+
if not ta or not tl or ca is None or cl is None or re_ is None or ebit is None:
|
|
117
|
+
return None
|
|
118
|
+
x1, x2, x3 = (ca - cl) / ta, re_ / ta, ebit / ta
|
|
119
|
+
|
|
120
|
+
if sector in MANUFACTURING and market_cap and sales is not None:
|
|
121
|
+
x4, x5 = market_cap / tl, sales / ta
|
|
122
|
+
z = 1.2 * x1 + 1.4 * x2 + 3.3 * x3 + 0.6 * x4 + 1.0 * x5
|
|
123
|
+
model, safe, distress = "Z (manufacturers)", 2.99, 1.81
|
|
124
|
+
parts = {"working_capital": x1, "retained_earnings": x2, "ebit": x3, "market_value_to_liabilities": x4, "sales": x5}
|
|
125
|
+
else:
|
|
126
|
+
if equity is None:
|
|
127
|
+
return None
|
|
128
|
+
x4 = equity / tl
|
|
129
|
+
z = 6.56 * x1 + 3.26 * x2 + 6.72 * x3 + 1.05 * x4
|
|
130
|
+
model, safe, distress = "Z'' (non-manufacturers, book equity)", 2.6, 1.1
|
|
131
|
+
parts = {"working_capital": x1, "retained_earnings": x2, "ebit": x3, "book_equity_to_liabilities": x4}
|
|
132
|
+
zone = "safe" if z > safe else "distress" if z < distress else "grey"
|
|
133
|
+
return {"score": round(z, 2), "model": model, "zone": zone, "safe_above": safe, "distress_below": distress,
|
|
134
|
+
"parts": {k: _r(v) for k, v in parts.items()}}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ Beneish
|
|
138
|
+
|
|
139
|
+
def beneish(inc: dict, bal: dict, cf: dict, financial: bool = False) -> Optional[dict]:
|
|
140
|
+
if financial:
|
|
141
|
+
return None
|
|
142
|
+
s0, s1 = _v(inc, "revenue", 0), _v(inc, "revenue", 1)
|
|
143
|
+
ta0, ta1 = _v(bal, "total_assets", 0), _v(bal, "total_assets", 1)
|
|
144
|
+
ni0, cfo0 = _v(inc, "net_income", 0), _v(cf, "operating_cf", 0)
|
|
145
|
+
if not s0 or not s1 or not ta0 or not ta1 or ni0 is None or cfo0 is None:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def at(key, i, src=bal):
|
|
149
|
+
return _v(src, key, i)
|
|
150
|
+
|
|
151
|
+
rec0, rec1 = at("receivables", 0), at("receivables", 1)
|
|
152
|
+
gm0, gm1 = _div(at("gross_profit", 0, inc), s0), _div(at("gross_profit", 1, inc), s1)
|
|
153
|
+
ppe0, ppe1 = at("ppe", 0), at("ppe", 1)
|
|
154
|
+
ca0, ca1 = at("current_assets", 0), at("current_assets", 1)
|
|
155
|
+
dep0, dep1 = abs(at("depreciation", 0, cf) or 0) or None, abs(at("depreciation", 1, cf) or 0) or None
|
|
156
|
+
sga0, sga1 = at("sga", 0, inc), at("sga", 1, inc)
|
|
157
|
+
tl0, tl1 = at("total_liabilities", 0), at("total_liabilities", 1)
|
|
158
|
+
|
|
159
|
+
def quality(ca, ppe, ta):
|
|
160
|
+
return 1 - (ca + ppe) / ta if ca is not None and ppe is not None else None
|
|
161
|
+
|
|
162
|
+
def dep_rate(dep, ppe):
|
|
163
|
+
return dep / (dep + ppe) if dep is not None and ppe is not None and dep + ppe else None
|
|
164
|
+
|
|
165
|
+
ratios = {
|
|
166
|
+
"DSRI": _div(_div(rec0, s0), _div(rec1, s1)), # receivables growing faster than sales
|
|
167
|
+
"GMI": _div(gm1, gm0), # gross margin falling
|
|
168
|
+
"AQI": _div(quality(ca0, ppe0, ta0), quality(ca1, ppe1, ta1)), # more soft assets
|
|
169
|
+
"SGI": s0 / s1, # sales growth
|
|
170
|
+
"DEPI": _div(dep_rate(dep1, ppe1), dep_rate(dep0, ppe0)), # slower depreciation
|
|
171
|
+
"SGAI": _div(_div(sga0, s0), _div(sga1, s1)), # overheads per sale
|
|
172
|
+
"LVGI": _div(_div(tl0, ta0), _div(tl1, ta1)), # more leverage
|
|
173
|
+
"TATA": (ni0 - cfo0) / ta0, # accruals
|
|
174
|
+
}
|
|
175
|
+
if ratios["DSRI"] is None:
|
|
176
|
+
return None
|
|
177
|
+
neutral = [k for k, v in ratios.items() if v is None]
|
|
178
|
+
vals = {k: (1.0 if v is None else v) for k, v in ratios.items()}
|
|
179
|
+
m = (-4.84 + 0.920 * vals["DSRI"] + 0.528 * vals["GMI"] + 0.404 * vals["AQI"] + 0.892 * vals["SGI"]
|
|
180
|
+
+ 0.115 * vals["DEPI"] - 0.172 * vals["SGAI"] + 4.679 * vals["TATA"] - 0.327 * vals["LVGI"])
|
|
181
|
+
return {"score": round(m, 2), "flag": m > -1.78, "threshold": -1.78,
|
|
182
|
+
"ratios": {k: _r(v) for k, v in ratios.items()}, "neutral": neutral}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def compute(inc: dict, bal: dict, cf: dict, *, sector: Optional[str] = None,
|
|
186
|
+
market_cap: Optional[float] = None, financial: bool = False) -> Optional[dict]:
|
|
187
|
+
out = {
|
|
188
|
+
"piotroski": piotroski(inc, bal, cf, financial),
|
|
189
|
+
"altman": altman(inc, bal, sector, market_cap, financial),
|
|
190
|
+
"beneish": beneish(inc, bal, cf, financial),
|
|
191
|
+
}
|
|
192
|
+
if financial:
|
|
193
|
+
return {**out, "financial_sector": True,
|
|
194
|
+
"note": "Not scored: these models are built for non-financial companies. Banks and insurers "
|
|
195
|
+
"book lending as operating cash flow and have no gross margin or current ratio."}
|
|
196
|
+
if not any(out.values()):
|
|
197
|
+
return None
|
|
198
|
+
out["financial_sector"] = False
|
|
199
|
+
return out
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Rates & bullion for markets other than India (India has its own,
|
|
2
|
+
richer card: marketmyna/services/india_rates.py).
|
|
3
|
+
|
|
4
|
+
- us: Fed funds, SOFR, Treasury 3M/2Y/10Y/30Y, 10y-2y, 30y mortgage
|
|
5
|
+
(FRED) + gold / silver in dollars (Yahoo futures).
|
|
6
|
+
- hong_kong: HKMA base rate, HIBOR overnight / 1M / 3M (HKMA open API),
|
|
7
|
+
USD/HKD peg + gold / silver in HK dollars.
|
|
8
|
+
- china_a: China 3M interbank (FRED/OECD, monthly), USD/CNY + gold per
|
|
9
|
+
gram and silver per kg in yuan.
|
|
10
|
+
- crypto: perpetual-futures funding rates (Binance, Bybit fallback),
|
|
11
|
+
annualised - crypto's closest thing to an interest rate - plus
|
|
12
|
+
Bitcoin priced in gold.
|
|
13
|
+
|
|
14
|
+
Every block is fetched independently; a failed block is reported and the
|
|
15
|
+
rest still render. Cached for an hour per market.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
24
|
+
from datetime import date, timedelta
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
CACHE_SECONDS = 3600
|
|
32
|
+
TROY_OZ_G = 31.1034768
|
|
33
|
+
TAEL_OZ = 1.20337 # 1 HK tael (37.429 g) in troy ounces
|
|
34
|
+
HKMA = "https://api.hkma.gov.hk/public/market-data-and-statistics"
|
|
35
|
+
FUNDING_COINS = ["BTC", "ETH", "SOL", "XRP", "DOGE", "BNB"]
|
|
36
|
+
|
|
37
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
38
|
+
_lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_json(url: str, params: Optional[dict] = None) -> dict:
|
|
42
|
+
import requests
|
|
43
|
+
|
|
44
|
+
r = requests.get(url, params=params, timeout=20, headers={"User-Agent": "Mozilla/5.0 MarketMyna"})
|
|
45
|
+
r.raise_for_status()
|
|
46
|
+
return r.json()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fred(series_id: str, start: date) -> pd.Series:
|
|
50
|
+
from .macro import _fred as fred
|
|
51
|
+
|
|
52
|
+
return fred(series_id, start)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
|
|
56
|
+
import yfinance as yf
|
|
57
|
+
|
|
58
|
+
from ..data import ecb_fx
|
|
59
|
+
from ..utils.yf_lock import YF_DOWNLOAD_LOCK
|
|
60
|
+
|
|
61
|
+
def download() -> dict[str, pd.Series]:
|
|
62
|
+
with YF_DOWNLOAD_LOCK:
|
|
63
|
+
df = yf.download(tickers, period="13mo", interval="1d", progress=False, auto_adjust=True, threads=True)
|
|
64
|
+
closes = df["Close"]
|
|
65
|
+
if isinstance(closes, pd.Series):
|
|
66
|
+
closes = closes.to_frame(tickers[0])
|
|
67
|
+
return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
|
|
68
|
+
|
|
69
|
+
return ecb_fx.guarded(download, tickers, days=400)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- helpers
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _naive(s: pd.Series) -> pd.Series:
|
|
76
|
+
idx = pd.DatetimeIndex(s.index)
|
|
77
|
+
return pd.Series(s.to_numpy(), index=idx.tz_localize(None) if idx.tz is not None else idx).sort_index()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ago(s: pd.Series, days: int) -> Optional[float]:
|
|
81
|
+
past = s[s.index <= s.index[-1] - pd.Timedelta(days=days)]
|
|
82
|
+
return float(past.iloc[-1]) if len(past) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def item(label: str, s: Optional[pd.Series], unit: str, note: str = "", scale: float = 1.0,
|
|
86
|
+
change: str = "points", digits: int = 2) -> Optional[dict]:
|
|
87
|
+
"""One stat from a series: latest value, 1-month and 1-year change.
|
|
88
|
+
|
|
89
|
+
change="points" for rates (difference), "pct" for prices (% change)."""
|
|
90
|
+
if s is None or s.dropna().empty:
|
|
91
|
+
return None
|
|
92
|
+
s = _naive(s.dropna()) * scale
|
|
93
|
+
now = float(s.iloc[-1])
|
|
94
|
+
|
|
95
|
+
def delta(days: int) -> Optional[float]:
|
|
96
|
+
past = _ago(s, days)
|
|
97
|
+
if past is None:
|
|
98
|
+
return None
|
|
99
|
+
return now - past if change == "points" else (now / past - 1) * 100 if past else None
|
|
100
|
+
|
|
101
|
+
return {"label": label, "value": round(now, digits), "unit": unit, "note": note,
|
|
102
|
+
"change_1m": delta(30), "change_1y": delta(365), "change_kind": change,
|
|
103
|
+
"as_of": str(s.index[-1].date()),
|
|
104
|
+
"spark": [round(float(v), digits) for v in s.resample("W").last().dropna().tail(52)]}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _section(title: str, items: list[Optional[dict]], note: str = "", source: str = "") -> Optional[dict]:
|
|
108
|
+
items = [i for i in items if i]
|
|
109
|
+
return {"title": title, "note": note, "source": source, "items": items} if items else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run(blocks: dict[str, Callable[[], Optional[dict]]]) -> tuple[list[dict], list[str]]:
|
|
113
|
+
sections, errors = [], []
|
|
114
|
+
with ThreadPoolExecutor(max_workers=len(blocks)) as pool:
|
|
115
|
+
futures = {name: pool.submit(fn) for name, fn in blocks.items()}
|
|
116
|
+
for name, fut in futures.items():
|
|
117
|
+
try:
|
|
118
|
+
sec = fut.result()
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.warning("Rates block %s failed: %s", name, exc)
|
|
121
|
+
errors.append(f"{name} unavailable right now")
|
|
122
|
+
continue
|
|
123
|
+
if sec:
|
|
124
|
+
sections.append(sec)
|
|
125
|
+
else:
|
|
126
|
+
errors.append(f"{name} unavailable right now")
|
|
127
|
+
return sections, errors
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _bullion(currency: str, fx: Optional[str], unit_note: str, *, per: str = "oz",
|
|
131
|
+
yahoo: Callable = _yahoo) -> Optional[dict]:
|
|
132
|
+
tickers = ["GC=F", "SI=F"] + ([fx] if fx else [])
|
|
133
|
+
data = yahoo(tickers)
|
|
134
|
+
gold, silver = data.get("GC=F"), data.get("SI=F")
|
|
135
|
+
if gold is None:
|
|
136
|
+
return None
|
|
137
|
+
gold, silver = _naive(gold), _naive(silver) if silver is not None else None
|
|
138
|
+
if fx:
|
|
139
|
+
rate = _naive(data[fx]) if fx in data else None
|
|
140
|
+
if rate is None:
|
|
141
|
+
return None
|
|
142
|
+
gold = pd.concat([gold, rate], axis=1).ffill().dropna().prod(axis=1)
|
|
143
|
+
silver = pd.concat([silver, rate], axis=1).ffill().dropna().prod(axis=1) if silver is not None else None
|
|
144
|
+
if per == "g":
|
|
145
|
+
g_item = item(f"Gold ({currency}/g)", gold, currency, unit_note, scale=1 / TROY_OZ_G, change="pct")
|
|
146
|
+
s_item = item(f"Silver ({currency}/kg)", silver, currency, "", scale=1000 / TROY_OZ_G, change="pct", digits=0)
|
|
147
|
+
elif per == "tael":
|
|
148
|
+
g_item = item(f"Gold ({currency}/tael)", gold, currency, unit_note, scale=TAEL_OZ, change="pct", digits=0)
|
|
149
|
+
s_item = item(f"Silver ({currency}/oz)", silver, currency, "", change="pct")
|
|
150
|
+
else:
|
|
151
|
+
g_item = item(f"Gold ({currency}/oz)", gold, currency, unit_note, change="pct", digits=0)
|
|
152
|
+
s_item = item(f"Silver ({currency}/oz)", silver, currency, "", change="pct")
|
|
153
|
+
ratio = None
|
|
154
|
+
if silver is not None:
|
|
155
|
+
both = pd.concat([gold, silver], axis=1, keys=["g", "s"]).ffill().dropna()
|
|
156
|
+
ratio = item("Gold / silver ratio", both["g"] / both["s"], "x",
|
|
157
|
+
"Ounces of silver one ounce of gold buys; high = silver cheap vs gold", change="points", digits=1)
|
|
158
|
+
return _section("Bullion", [g_item, s_item, ratio], source="COMEX futures, market data feed")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------- markets
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _us(fred=_fred, yahoo=_yahoo) -> dict:
|
|
165
|
+
start = date.today() - timedelta(days=400)
|
|
166
|
+
|
|
167
|
+
def series(*ids):
|
|
168
|
+
return {i: fred(i, start) for i in ids}
|
|
169
|
+
|
|
170
|
+
def policy():
|
|
171
|
+
s = series("DFF", "SOFR", "MORTGAGE30US")
|
|
172
|
+
return _section("Policy & money market", [
|
|
173
|
+
item("Fed funds (effective)", s["DFF"], "%", "Overnight rate the Fed steers"),
|
|
174
|
+
item("SOFR", s["SOFR"], "%", "Secured overnight rate; benchmark for most US loans"),
|
|
175
|
+
item("30-year mortgage", s["MORTGAGE30US"], "%", "Freddie Mac weekly average"),
|
|
176
|
+
], source="Official US rates data")
|
|
177
|
+
|
|
178
|
+
def treasuries():
|
|
179
|
+
s = series("DGS3MO", "DGS2", "DGS10", "DGS30", "T10Y2Y")
|
|
180
|
+
return _section("Treasury yields", [
|
|
181
|
+
item("3-month", s["DGS3MO"], "%", "Cash-like yield"),
|
|
182
|
+
item("2-year", s["DGS2"], "%", "Tracks expected Fed moves"),
|
|
183
|
+
item("10-year", s["DGS10"], "%", "Benchmark for valuations and mortgages"),
|
|
184
|
+
item("30-year", s["DGS30"], "%"),
|
|
185
|
+
item("10y − 2y spread", s["T10Y2Y"], "pts", "Below 0 (inverted) has preceded most recessions"),
|
|
186
|
+
], note="Full curve: see the Macro tab.", source="Official US Treasury constant-maturity yields")
|
|
187
|
+
|
|
188
|
+
sections, errors = _run({"Policy rates": policy, "Treasuries": treasuries,
|
|
189
|
+
"Bullion": lambda: _bullion("$", None, "COMEX front-month", yahoo=yahoo)})
|
|
190
|
+
return {"sections": sections, "errors": errors}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _hkma_series(path: str, fields: dict[str, str], date_key: str, pages: int = 400) -> dict[str, pd.Series]:
|
|
194
|
+
data = _get_json(f"{HKMA}/{path}", {"pagesize": pages, "sortby": date_key, "sortorder": "desc"})
|
|
195
|
+
records = (data.get("result") or {}).get("records") or []
|
|
196
|
+
out = {}
|
|
197
|
+
for key in fields:
|
|
198
|
+
pts = {pd.Timestamp(r[date_key]): r[key] for r in records if r.get(key) is not None}
|
|
199
|
+
if pts:
|
|
200
|
+
out[key] = pd.Series(pts).sort_index().astype(float)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _hong_kong(yahoo=_yahoo) -> dict:
|
|
205
|
+
def money():
|
|
206
|
+
daily = _hkma_series("daily-monetary-statistics/daily-figures-interbank-liquidity",
|
|
207
|
+
{"disc_win_base_rate": "", "hibor_overnight": "", "hibor_fixing_1m": ""}, "end_of_date")
|
|
208
|
+
curve = _hkma_series("monthly-statistical-bulletin/er-ir/hk-interbank-ir-daily",
|
|
209
|
+
{"ir_3m": "", "ir_12m": ""}, "end_of_day")
|
|
210
|
+
return _section("HKMA & HIBOR", [
|
|
211
|
+
item("Base rate", daily.get("disc_win_base_rate"), "%", "HKMA discount-window base rate; follows the Fed under the peg"),
|
|
212
|
+
item("HIBOR overnight", daily.get("hibor_overnight"), "%", "Swings with HK dollar liquidity"),
|
|
213
|
+
item("HIBOR 1M", daily.get("hibor_fixing_1m"), "%", "Prices most HK mortgages (HIBOR-linked plans)"),
|
|
214
|
+
item("HIBOR 3M", curve.get("ir_3m"), "%"),
|
|
215
|
+
item("HIBOR 12M", curve.get("ir_12m"), "%"),
|
|
216
|
+
], source="HKMA open API")
|
|
217
|
+
|
|
218
|
+
def peg():
|
|
219
|
+
data = yahoo(["HKD=X"])
|
|
220
|
+
return _section("Currency peg", [
|
|
221
|
+
item("USD / HKD", data.get("HKD=X"), "HK$", "Pegged in the 7.75 – 7.85 band; near 7.85 = weak side, HKMA drains liquidity",
|
|
222
|
+
change="points", digits=4),
|
|
223
|
+
], source="Market data feed")
|
|
224
|
+
|
|
225
|
+
sections, errors = _run({"HKMA rates": money, "Currency peg": peg,
|
|
226
|
+
"Bullion": lambda: _bullion("HK$", "HKD=X", "Spot in HK dollars", per="tael", yahoo=yahoo)})
|
|
227
|
+
return {"sections": sections, "errors": errors}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _china(fred=_fred, yahoo=_yahoo) -> dict:
|
|
231
|
+
start = date.today() - timedelta(days=800)
|
|
232
|
+
|
|
233
|
+
def money():
|
|
234
|
+
return _section("Money market", [
|
|
235
|
+
item("3-month interbank", fred("IR3TIB01CNM156N", start), "%", "Monthly (OECD); lags a few months"),
|
|
236
|
+
], source="Official central bank and OECD statistics")
|
|
237
|
+
|
|
238
|
+
def fx():
|
|
239
|
+
data = yahoo(["CNY=X"])
|
|
240
|
+
return _section("Currency", [
|
|
241
|
+
item("USD / CNY", data.get("CNY=X"), "¥", "Higher = weaker yuan; PBoC manages it in a daily band",
|
|
242
|
+
change="points", digits=4),
|
|
243
|
+
], source="Market data feed")
|
|
244
|
+
|
|
245
|
+
sections, errors = _run({"Money market": money, "Currency": fx,
|
|
246
|
+
"Bullion": lambda: _bullion("¥", "CNY=X", "Spot converted to yuan", per="g", yahoo=yahoo)})
|
|
247
|
+
return {"sections": sections, "errors": errors}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _funding_binance() -> list[dict]:
|
|
251
|
+
rows = _get_json("https://fapi.binance.com/fapi/v1/premiumIndex")
|
|
252
|
+
by = {r["symbol"]: r for r in rows if isinstance(r, dict)}
|
|
253
|
+
out = []
|
|
254
|
+
for coin in FUNDING_COINS:
|
|
255
|
+
r = by.get(f"{coin}USDT")
|
|
256
|
+
if r and r.get("lastFundingRate") not in (None, ""):
|
|
257
|
+
out.append({"coin": coin, "rate_8h": float(r["lastFundingRate"]), "mark": float(r["markPrice"]),
|
|
258
|
+
"next": int(r.get("nextFundingTime") or 0)})
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _funding_bybit() -> list[dict]:
|
|
263
|
+
data = _get_json("https://api.bybit.com/v5/market/tickers", {"category": "linear"})
|
|
264
|
+
by = {r["symbol"]: r for r in (data.get("result") or {}).get("list") or []}
|
|
265
|
+
out = []
|
|
266
|
+
for coin in FUNDING_COINS:
|
|
267
|
+
r = by.get(f"{coin}USDT")
|
|
268
|
+
if r and r.get("fundingRate") not in (None, ""):
|
|
269
|
+
out.append({"coin": coin, "rate_8h": float(r["fundingRate"]), "mark": float(r["markPrice"]),
|
|
270
|
+
"next": int(r.get("nextFundingTime") or 0)})
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _crypto(yahoo=_yahoo, funding_sources=(_funding_binance, _funding_bybit)) -> dict:
|
|
275
|
+
def funding():
|
|
276
|
+
rows, source = [], ""
|
|
277
|
+
for fn in funding_sources:
|
|
278
|
+
try:
|
|
279
|
+
rows = fn()
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
logger.warning("Funding rates via %s failed: %s", fn.__name__, exc)
|
|
282
|
+
continue
|
|
283
|
+
if rows:
|
|
284
|
+
source = "Crypto exchange futures data"
|
|
285
|
+
break
|
|
286
|
+
items = []
|
|
287
|
+
for r in rows:
|
|
288
|
+
annual = r["rate_8h"] * 3 * 365 * 100
|
|
289
|
+
items.append({
|
|
290
|
+
"label": f"{r['coin']} funding", "value": round(annual, 1), "unit": "% / yr",
|
|
291
|
+
"note": f"{r['rate_8h'] * 100:.4f}% per 8h · "
|
|
292
|
+
+ ("longs pay shorts (bullish leverage)" if r["rate_8h"] > 0 else "shorts pay longs (bearish leverage)"),
|
|
293
|
+
"change_1m": None, "change_1y": None, "change_kind": "points", "as_of": str(date.today()), "spark": [],
|
|
294
|
+
"tone": "up" if annual > 0 else "down",
|
|
295
|
+
})
|
|
296
|
+
return _section("Perpetual funding rates", items,
|
|
297
|
+
note="What leveraged longs pay shorts, annualised. Very high = crowded longs; negative = crowded shorts.",
|
|
298
|
+
source=f"{source} USDT perpetuals" if source else "")
|
|
299
|
+
|
|
300
|
+
def btc_gold():
|
|
301
|
+
data = yahoo(["BTC-USD", "GC=F"])
|
|
302
|
+
if "BTC-USD" not in data or "GC=F" not in data:
|
|
303
|
+
return None
|
|
304
|
+
both = pd.concat([_naive(data["BTC-USD"]), _naive(data["GC=F"])], axis=1, keys=["b", "g"]).ffill().dropna()
|
|
305
|
+
return _section("Bitcoin vs gold", [
|
|
306
|
+
item("BTC in gold ounces", both["b"] / both["g"], "oz", "How many ounces of gold one bitcoin buys", change="pct", digits=1),
|
|
307
|
+
item("Gold ($/oz)", data["GC=F"], "$", "COMEX front-month", change="pct", digits=0),
|
|
308
|
+
], source="Market data feed")
|
|
309
|
+
|
|
310
|
+
sections, errors = _run({"Funding rates": funding, "Bitcoin vs gold": btc_gold})
|
|
311
|
+
return {"sections": sections, "errors": errors}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
BUILDERS: dict[str, Callable[[], dict]] = {
|
|
315
|
+
"us": _us, "hong_kong": _hong_kong, "china_a": _china, "crypto": _crypto,
|
|
316
|
+
}
|
|
317
|
+
TITLES = {"us": "US rates & bullion", "hong_kong": "Hong Kong rates & bullion",
|
|
318
|
+
"china_a": "China rates & bullion", "crypto": "Funding rates & gold"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def build(market: str, use_cache: bool = True) -> dict:
|
|
322
|
+
market = market.lower()
|
|
323
|
+
builder = BUILDERS.get(market)
|
|
324
|
+
if builder is None:
|
|
325
|
+
return {"market": market, "sections": [], "errors": ["Not available for this market"]}
|
|
326
|
+
if use_cache:
|
|
327
|
+
with _lock:
|
|
328
|
+
hit = _cache.get(market)
|
|
329
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
330
|
+
return hit[1]
|
|
331
|
+
out = {"market": market, "title": TITLES[market], **builder()}
|
|
332
|
+
if use_cache and out["sections"]:
|
|
333
|
+
with _lock:
|
|
334
|
+
_cache[market] = (time.time(), out)
|
|
335
|
+
return out
|