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,173 @@
|
|
|
1
|
+
"""Circuit-breaker-protected fallback chains for data fetchers.
|
|
2
|
+
|
|
3
|
+
Mechanics borrowed from daily_stock_analysis's data_provider circuit
|
|
4
|
+
breaker (closed -> open -> half_open state machine, per-source failure
|
|
5
|
+
count + cooldown), reimplemented here against MarketMyna's own,
|
|
6
|
+
differently-shaped DataFetcher interface rather than copied verbatim.
|
|
7
|
+
|
|
8
|
+
Key design choice: state is keyed by an arbitrary string, not just the
|
|
9
|
+
source name, so callers can compose "{source}:{method}" keys. A source
|
|
10
|
+
failing on get_quote should not necessarily open the circuit for that
|
|
11
|
+
same source's get_history - the two calls can have very different
|
|
12
|
+
failure modes (e.g. a quote endpoint rate-limited while history is
|
|
13
|
+
fine), so isolating per-method avoids over-tripping the breaker.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from threading import RLock
|
|
21
|
+
from typing import Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DataSourceUnavailable(Exception):
|
|
27
|
+
"""Every source in a FallbackChain failed or is circuit-open."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CircuitBreaker:
|
|
31
|
+
"""Per-key closed/open/half_open state machine.
|
|
32
|
+
|
|
33
|
+
- CLOSED: normal, calls go through.
|
|
34
|
+
- OPEN: skip calls for `cooldown_seconds` after `failure_threshold`
|
|
35
|
+
consecutive failures.
|
|
36
|
+
- HALF_OPEN: after cooldown, allow `half_open_max_calls` probe(s);
|
|
37
|
+
success fully recovers, failure re-opens for another cooldown.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
CLOSED = "closed"
|
|
41
|
+
OPEN = "open"
|
|
42
|
+
HALF_OPEN = "half_open"
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
failure_threshold: int = 3,
|
|
47
|
+
cooldown_seconds: float = 300.0,
|
|
48
|
+
half_open_max_calls: int = 1,
|
|
49
|
+
):
|
|
50
|
+
self.failure_threshold = failure_threshold
|
|
51
|
+
self.cooldown_seconds = cooldown_seconds
|
|
52
|
+
self.half_open_max_calls = half_open_max_calls
|
|
53
|
+
self._states: dict[str, dict[str, Any]] = {}
|
|
54
|
+
self._lock = RLock()
|
|
55
|
+
|
|
56
|
+
def _state(self, key: str) -> dict[str, Any]:
|
|
57
|
+
if key not in self._states:
|
|
58
|
+
self._states[key] = {
|
|
59
|
+
"state": self.CLOSED,
|
|
60
|
+
"failures": 0,
|
|
61
|
+
"last_failure_time": 0.0,
|
|
62
|
+
"half_open_calls": 0,
|
|
63
|
+
}
|
|
64
|
+
return self._states[key]
|
|
65
|
+
|
|
66
|
+
def is_available(self, key: str) -> bool:
|
|
67
|
+
"""True if a call should be attempted for this key."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
state = self._state(key)
|
|
70
|
+
now = time.time()
|
|
71
|
+
|
|
72
|
+
if state["state"] == self.CLOSED:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
if state["state"] == self.OPEN:
|
|
76
|
+
elapsed = now - state["last_failure_time"]
|
|
77
|
+
if elapsed < self.cooldown_seconds:
|
|
78
|
+
return False
|
|
79
|
+
state["state"] = self.HALF_OPEN
|
|
80
|
+
state["half_open_calls"] = 0
|
|
81
|
+
logger.info("Circuit breaker %s: cooldown elapsed, half-opening", key)
|
|
82
|
+
|
|
83
|
+
# HALF_OPEN
|
|
84
|
+
if state["half_open_calls"] < self.half_open_max_calls:
|
|
85
|
+
state["half_open_calls"] += 1
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def record_success(self, key: str) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
state = self._state(key)
|
|
92
|
+
if state["state"] == self.HALF_OPEN:
|
|
93
|
+
logger.info("Circuit breaker %s: probe succeeded, closing", key)
|
|
94
|
+
state["state"] = self.CLOSED
|
|
95
|
+
state["failures"] = 0
|
|
96
|
+
state["half_open_calls"] = 0
|
|
97
|
+
|
|
98
|
+
def record_failure(self, key: str, error: Optional[str] = None) -> None:
|
|
99
|
+
with self._lock:
|
|
100
|
+
state = self._state(key)
|
|
101
|
+
state["failures"] += 1
|
|
102
|
+
state["last_failure_time"] = time.time()
|
|
103
|
+
|
|
104
|
+
if state["state"] == self.HALF_OPEN:
|
|
105
|
+
state["state"] = self.OPEN
|
|
106
|
+
state["half_open_calls"] = 0
|
|
107
|
+
logger.warning("Circuit breaker %s: probe failed, re-opening", key)
|
|
108
|
+
elif state["failures"] >= self.failure_threshold:
|
|
109
|
+
state["state"] = self.OPEN
|
|
110
|
+
logger.warning(
|
|
111
|
+
"Circuit breaker %s: %d consecutive failures, opening for %.0fs (%s)",
|
|
112
|
+
key, state["failures"], self.cooldown_seconds, error or "",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def record_inconclusive(self, key: str) -> None:
|
|
116
|
+
"""An empty/unusable (but non-exception) result - only matters in
|
|
117
|
+
HALF_OPEN, where it re-opens the breaker rather than fully closing."""
|
|
118
|
+
with self._lock:
|
|
119
|
+
state = self._state(key)
|
|
120
|
+
if state["state"] == self.HALF_OPEN:
|
|
121
|
+
state["state"] = self.OPEN
|
|
122
|
+
state["half_open_calls"] = 0
|
|
123
|
+
state["last_failure_time"] = time.time()
|
|
124
|
+
|
|
125
|
+
def status(self) -> dict[str, str]:
|
|
126
|
+
with self._lock:
|
|
127
|
+
return {key: info["state"] for key, info in self._states.items()}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Shared default breaker: fetcher instances come and go (a new
|
|
131
|
+
# PortfolioManager/StockDataFetcher per API request, per Phase 0's
|
|
132
|
+
# get_portfolio_manager pattern) but failure history should persist
|
|
133
|
+
# across those instances for the breaker to mean anything.
|
|
134
|
+
default_breaker = CircuitBreaker()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class FallbackChain:
|
|
138
|
+
"""An ordered list of named callables for one logical operation
|
|
139
|
+
(e.g. "get_quote"), tried in order with circuit-breaker protection."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, method: str, breaker: Optional[CircuitBreaker] = None):
|
|
142
|
+
self.method = method
|
|
143
|
+
self.breaker = breaker or default_breaker
|
|
144
|
+
self._sources: list[tuple[str, Callable[..., Any]]] = []
|
|
145
|
+
|
|
146
|
+
def add(self, name: str, func: Callable[..., Any]) -> "FallbackChain":
|
|
147
|
+
self._sources.append((name, func))
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def call(self, *args: Any, is_empty: Optional[Callable[[Any], bool]] = None, **kwargs: Any) -> Any:
|
|
151
|
+
last_exc: Optional[Exception] = None
|
|
152
|
+
for name, func in self._sources:
|
|
153
|
+
key = f"{name}:{self.method}"
|
|
154
|
+
if not self.breaker.is_available(key):
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
result = func(*args, **kwargs)
|
|
158
|
+
except Exception as exc: # noqa: BLE001 - deliberately broad, this is the fallback boundary
|
|
159
|
+
self.breaker.record_failure(key, str(exc))
|
|
160
|
+
last_exc = exc
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if is_empty is not None and is_empty(result):
|
|
164
|
+
self.breaker.record_inconclusive(key)
|
|
165
|
+
last_exc = last_exc or RuntimeError(f"{name} returned an empty result")
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
self.breaker.record_success(key)
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
raise DataSourceUnavailable(
|
|
172
|
+
f"All sources exhausted for {self.method}"
|
|
173
|
+
) from last_exc
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""US financial statements from SEC XBRL company facts (data.sec.gov, no key).
|
|
2
|
+
|
|
3
|
+
companyfacts holds every number a company has tagged in its 10-K / 10-Q
|
|
4
|
+
filings since about 2009. This turns the common US-GAAP tags into the same
|
|
5
|
+
three DataFrames yfinance returns (rows named like Yahoo's, columns = period
|
|
6
|
+
end dates, newest first), so marketmyna/services/financials.py and the quality
|
|
7
|
+
scores can use either source. Used when Yahoo has no statements and for
|
|
8
|
+
longer histories (Yahoo keeps 4-5 years).
|
|
9
|
+
|
|
10
|
+
Total debt excludes lease liabilities (Yahoo includes them).
|
|
11
|
+
Annual = 10-K full-year figures. Quarterly = three-month figures; the fourth
|
|
12
|
+
quarter, which companies do not file separately, is the year minus the first
|
|
13
|
+
three quarters. Flows that are outflows (capex, dividends, buybacks, debt
|
|
14
|
+
repaid) are made negative, as Yahoo shows them. SEC needs SEC_USER_AGENT
|
|
15
|
+
(a name and contact email), see marketmyna/services/sec_mdna.py.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from datetime import date
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik:010d}.json"
|
|
28
|
+
CACHE_SECONDS = 12 * 3600
|
|
29
|
+
|
|
30
|
+
# Yahoo row name -> US-GAAP tags, first one with data wins
|
|
31
|
+
INCOME = {
|
|
32
|
+
"Total Revenue": ["Revenues", "RevenueFromContractWithCustomerExcludingAssessedTax", "SalesRevenueNet",
|
|
33
|
+
"RevenueFromContractWithCustomerIncludingAssessedTax", "SalesRevenueGoodsNet",
|
|
34
|
+
"RevenuesNetOfInterestExpense"],
|
|
35
|
+
"Cost Of Revenue": ["CostOfRevenue", "CostOfGoodsAndServicesSold", "CostOfGoodsSold"],
|
|
36
|
+
"Gross Profit": ["GrossProfit"],
|
|
37
|
+
"Operating Expense": ["OperatingExpenses"],
|
|
38
|
+
"Selling General And Administration": ["SellingGeneralAndAdministrativeExpense"],
|
|
39
|
+
"Operating Income": ["OperatingIncomeLoss"],
|
|
40
|
+
"Interest Expense": ["InterestExpense", "InterestExpenseNonoperating", "InterestExpenseDebt"],
|
|
41
|
+
"Pretax Income": ["IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest",
|
|
42
|
+
"IncomeLossFromContinuingOperationsBeforeIncomeTaxesMinorityInterestAndIncomeLossFromEquityMethodInvestments"],
|
|
43
|
+
"Tax Provision": ["IncomeTaxExpenseBenefit"],
|
|
44
|
+
"Net Income": ["NetIncomeLoss", "ProfitLoss"],
|
|
45
|
+
"Diluted EPS": ["EarningsPerShareDiluted", "EarningsPerShareBasicAndDiluted"],
|
|
46
|
+
"Basic EPS": ["EarningsPerShareBasic"],
|
|
47
|
+
"Diluted Average Shares": ["WeightedAverageNumberOfDilutedSharesOutstanding"],
|
|
48
|
+
}
|
|
49
|
+
BALANCE = {
|
|
50
|
+
"Total Assets": ["Assets"],
|
|
51
|
+
"Current Assets": ["AssetsCurrent"],
|
|
52
|
+
"Cash And Cash Equivalents": ["CashAndCashEquivalentsAtCarryingValue", "Cash"],
|
|
53
|
+
"Short Term Investments": ["ShortTermInvestments", "MarketableSecuritiesCurrent", "AvailableForSaleSecuritiesDebtSecuritiesCurrent"],
|
|
54
|
+
"Accounts Receivable": ["AccountsReceivableNetCurrent", "ReceivablesNetCurrent"],
|
|
55
|
+
"Inventory": ["InventoryNet"],
|
|
56
|
+
"Net PPE": ["PropertyPlantAndEquipmentNet"],
|
|
57
|
+
"Total Liabilities Net Minority Interest": ["Liabilities"],
|
|
58
|
+
"Current Liabilities": ["LiabilitiesCurrent"],
|
|
59
|
+
"Long Term Debt": ["LongTermDebtNoncurrent", "LongTermDebt"],
|
|
60
|
+
"Current Debt": ["LongTermDebtCurrent", "DebtCurrent"],
|
|
61
|
+
"Commercial Paper": ["CommercialPaper", "ShortTermBorrowings"],
|
|
62
|
+
"Stockholders Equity": ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
|
|
63
|
+
"Retained Earnings": ["RetainedEarningsAccumulatedDeficit"],
|
|
64
|
+
"Goodwill": ["Goodwill"],
|
|
65
|
+
"Other Intangible Assets": ["IntangibleAssetsNetExcludingGoodwill", "FiniteLivedIntangibleAssetsNet"],
|
|
66
|
+
"Ordinary Shares Number": ["CommonStockSharesOutstanding"],
|
|
67
|
+
}
|
|
68
|
+
CASHFLOW = {
|
|
69
|
+
"Operating Cash Flow": ["NetCashProvidedByUsedInOperatingActivities",
|
|
70
|
+
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations"],
|
|
71
|
+
"Capital Expenditure": ["PaymentsToAcquirePropertyPlantAndEquipment", "PaymentsToAcquireProductiveAssets"],
|
|
72
|
+
"Investing Cash Flow": ["NetCashProvidedByUsedInInvestingActivities",
|
|
73
|
+
"NetCashProvidedByUsedInInvestingActivitiesContinuingOperations"],
|
|
74
|
+
"Financing Cash Flow": ["NetCashProvidedByUsedInFinancingActivities",
|
|
75
|
+
"NetCashProvidedByUsedInFinancingActivitiesContinuingOperations"],
|
|
76
|
+
"Cash Dividends Paid": ["PaymentsOfDividends", "PaymentsOfDividendsCommonStock"],
|
|
77
|
+
"Repurchase Of Capital Stock": ["PaymentsForRepurchaseOfCommonStock"],
|
|
78
|
+
"Issuance Of Debt": ["ProceedsFromIssuanceOfLongTermDebt", "ProceedsFromIssuanceOfDebt"],
|
|
79
|
+
"Repayment Of Debt": ["RepaymentsOfLongTermDebt", "RepaymentsOfDebt"],
|
|
80
|
+
"Stock Based Compensation": ["ShareBasedCompensation", "AllocatedShareBasedCompensationExpense"],
|
|
81
|
+
"Depreciation And Amortization": ["DepreciationDepletionAndAmortization", "DepreciationAndAmortization",
|
|
82
|
+
"DepreciationAmortizationAndAccretionNet"],
|
|
83
|
+
"Changes In Cash": ["CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalentsPeriodIncreaseDecreaseIncludingExchangeRateEffect",
|
|
84
|
+
"CashAndCashEquivalentsPeriodIncreaseDecrease"],
|
|
85
|
+
}
|
|
86
|
+
OUTFLOWS = {"Capital Expenditure", "Cash Dividends Paid", "Repurchase Of Capital Stock", "Repayment Of Debt"}
|
|
87
|
+
PER_SHARE = {"Diluted EPS", "Basic EPS"}
|
|
88
|
+
# Not sums over time: no quarter is derived from year-to-date or full-year figures
|
|
89
|
+
NON_ADDITIVE = PER_SHARE | {"Diluted Average Shares"}
|
|
90
|
+
|
|
91
|
+
_cache: dict[int, tuple[float, dict]] = {}
|
|
92
|
+
_lock = threading.Lock()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def company_facts(symbol: str) -> dict:
|
|
96
|
+
from ..services.sec_mdna import _get, cik_for
|
|
97
|
+
|
|
98
|
+
cik = cik_for(symbol)
|
|
99
|
+
with _lock:
|
|
100
|
+
hit = _cache.get(cik)
|
|
101
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
102
|
+
return hit[1]
|
|
103
|
+
facts = _get(FACTS_URL.format(cik=cik)).json()
|
|
104
|
+
with _lock:
|
|
105
|
+
_cache[cik] = (time.time(), facts)
|
|
106
|
+
return facts
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _days(start: str, end: str) -> int:
|
|
110
|
+
return (date.fromisoformat(end) - date.fromisoformat(start)).days
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _entries(facts: dict, tag: str) -> list[dict]:
|
|
114
|
+
node = ((facts.get("facts") or {}).get("us-gaap") or {}).get(tag) or {}
|
|
115
|
+
units = node.get("units") or {}
|
|
116
|
+
for unit in ("USD", "USD/shares", "shares"):
|
|
117
|
+
if unit in units:
|
|
118
|
+
return units[unit]
|
|
119
|
+
return next(iter(units.values()), [])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _latest_by_end(rows: list[dict]) -> dict[str, float]:
|
|
123
|
+
"""end date -> value, keeping the most recently filed figure (restatements win)."""
|
|
124
|
+
best: dict[str, tuple[str, float]] = {}
|
|
125
|
+
for r in rows:
|
|
126
|
+
end, filed = r.get("end"), r.get("filed") or ""
|
|
127
|
+
if end and (end not in best or filed >= best[end][0]):
|
|
128
|
+
best[end] = (filed, float(r["val"]))
|
|
129
|
+
return {end: v for end, (_, v) in best.items()}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _flow_one(facts: dict, tag: str, quarterly: bool, additive: bool = True) -> dict[str, float]:
|
|
133
|
+
entries = [e for e in _entries(facts, tag) if e.get("start") and e.get("end")]
|
|
134
|
+
if not entries:
|
|
135
|
+
return {}
|
|
136
|
+
yearly = [e for e in entries if 350 <= _days(e["start"], e["end"]) <= 380]
|
|
137
|
+
annual = _latest_by_end([e for e in yearly if (e.get("form") or "").startswith("10-K")])
|
|
138
|
+
if not quarterly:
|
|
139
|
+
return annual
|
|
140
|
+
quarters = _latest_by_end([e for e in entries if 80 <= _days(e["start"], e["end"]) <= 100])
|
|
141
|
+
if not additive:
|
|
142
|
+
return quarters
|
|
143
|
+
# Cash-flow statements in a 10-Q are year to date (6 or 9 months): a quarter
|
|
144
|
+
# is the year-to-date figure minus the one before it from the same start.
|
|
145
|
+
by_start: dict[str, dict[str, float]] = {}
|
|
146
|
+
for e in entries:
|
|
147
|
+
by_start.setdefault(e["start"], {})
|
|
148
|
+
for start in by_start:
|
|
149
|
+
by_start[start] = _latest_by_end([e for e in entries if e["start"] == start])
|
|
150
|
+
for start, ytd in by_start.items():
|
|
151
|
+
ends = sorted(ytd)
|
|
152
|
+
for prev, end in zip(ends, ends[1:]):
|
|
153
|
+
if end not in quarters and 80 <= _days(prev, end) <= 100:
|
|
154
|
+
quarters[end] = ytd[end] - ytd[prev]
|
|
155
|
+
# Q4 = year minus the three quarters inside it
|
|
156
|
+
for end, total in annual.items():
|
|
157
|
+
if end in quarters:
|
|
158
|
+
continue
|
|
159
|
+
start = next(e["start"] for e in yearly if e["end"] == end)
|
|
160
|
+
inside = [v for q_end, v in quarters.items() if start < q_end < end]
|
|
161
|
+
if len(inside) == 3:
|
|
162
|
+
quarters[end] = round(total - sum(inside), 4)
|
|
163
|
+
return quarters
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _instant_one(facts: dict, tag: str, quarterly: bool) -> dict[str, float]:
|
|
167
|
+
entries = [e for e in _entries(facts, tag) if e.get("end") and not e.get("start")]
|
|
168
|
+
if not quarterly:
|
|
169
|
+
entries = [e for e in entries if (e.get("form") or "").startswith("10-K") and e.get("fp") == "FY"]
|
|
170
|
+
return _latest_by_end(entries)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _merge(series: list[dict[str, float]]) -> dict[str, float]:
|
|
174
|
+
"""Companies switch tags over the years (Apple's revenue moved from Revenues to
|
|
175
|
+
RevenueFromContractWithCustomer... in 2018): each period takes the first tag,
|
|
176
|
+
in priority order, that has it."""
|
|
177
|
+
out: dict[str, float] = {}
|
|
178
|
+
for values in series:
|
|
179
|
+
for end, v in values.items():
|
|
180
|
+
out.setdefault(end, v)
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def flow_series(facts: dict, tags: list[str], quarterly: bool, additive: bool = True) -> dict[str, float]:
|
|
185
|
+
"""Figures over a period (income, cash flow) by period end."""
|
|
186
|
+
return _merge([_flow_one(facts, t, quarterly, additive) for t in tags])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def instant_series(facts: dict, tags: list[str], quarterly: bool) -> dict[str, float]:
|
|
190
|
+
"""Balances at a date (balance sheet), from 10-K (annual) or any filing (quarterly)."""
|
|
191
|
+
return _merge([_instant_one(facts, t, quarterly) for t in tags])
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _frame(series: dict[str, dict[str, float]], periods: list[str]) -> pd.DataFrame:
|
|
195
|
+
cols = [pd.Timestamp(p) for p in periods]
|
|
196
|
+
data = {name: [values.get(p) for p in periods] for name, values in series.items() if values}
|
|
197
|
+
return pd.DataFrame.from_dict(data, orient="index", columns=cols) if data else pd.DataFrame(columns=cols)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def statements(symbol: str, quarterly: bool = False, max_periods: Optional[int] = None,
|
|
201
|
+
facts: Optional[dict] = None) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
202
|
+
"""(income, balance, cashflow) like yfinance's, newest period first."""
|
|
203
|
+
facts = facts if facts is not None else company_facts(symbol)
|
|
204
|
+
limit = max_periods or (5 if quarterly else 10)
|
|
205
|
+
|
|
206
|
+
income = {name: flow_series(facts, tags, quarterly, name not in NON_ADDITIVE) for name, tags in INCOME.items()}
|
|
207
|
+
cash = {name: flow_series(facts, tags, quarterly) for name, tags in CASHFLOW.items()}
|
|
208
|
+
balance = {name: instant_series(facts, tags, quarterly) for name, tags in BALANCE.items()}
|
|
209
|
+
for name in OUTFLOWS:
|
|
210
|
+
cash[name] = {k: -abs(v) for k, v in cash[name].items()}
|
|
211
|
+
|
|
212
|
+
# Revenue or net income: banks stop tagging quarterly revenue under one tag for years
|
|
213
|
+
periods = sorted(set(income["Total Revenue"]) | set(income["Net Income"]), reverse=True)[:limit]
|
|
214
|
+
if not periods:
|
|
215
|
+
raise ValueError(f"No SEC financial statements tagged for {symbol}")
|
|
216
|
+
|
|
217
|
+
# Balance sheets dated a few days off the income period end are matched to it.
|
|
218
|
+
def align(values: dict[str, float]) -> dict[str, float]:
|
|
219
|
+
out = {}
|
|
220
|
+
for p in periods:
|
|
221
|
+
if p in values:
|
|
222
|
+
out[p] = values[p]
|
|
223
|
+
continue
|
|
224
|
+
near = [d for d in values if abs(_days(d, p)) <= 7]
|
|
225
|
+
if near:
|
|
226
|
+
out[p] = values[min(near, key=lambda d: abs(_days(d, p)))]
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
balance = {k: align(v) for k, v in balance.items()}
|
|
230
|
+
cash = {k: align(v) for k, v in cash.items()}
|
|
231
|
+
|
|
232
|
+
# Derived rows Yahoo also shows
|
|
233
|
+
def combine(*rows: dict[str, float]) -> dict[str, float]:
|
|
234
|
+
return {p: sum(r[p] for r in rows if p in r) for p in periods if any(p in r for r in rows)}
|
|
235
|
+
|
|
236
|
+
if not income["Gross Profit"] and income["Total Revenue"] and income["Cost Of Revenue"]:
|
|
237
|
+
income["Gross Profit"] = {p: income["Total Revenue"][p] - income["Cost Of Revenue"][p]
|
|
238
|
+
for p in periods if p in income["Total Revenue"] and p in income["Cost Of Revenue"]}
|
|
239
|
+
if income["Operating Income"] and cash["Depreciation And Amortization"]:
|
|
240
|
+
income["EBITDA"] = combine(income["Operating Income"], cash["Depreciation And Amortization"])
|
|
241
|
+
balance["Total Debt"] = combine(balance.pop("Long Term Debt"), balance.pop("Current Debt"),
|
|
242
|
+
balance.pop("Commercial Paper"))
|
|
243
|
+
balance["Cash Cash Equivalents And Short Term Investments"] = combine(
|
|
244
|
+
balance["Cash And Cash Equivalents"], balance.pop("Short Term Investments"))
|
|
245
|
+
if balance["Total Debt"]:
|
|
246
|
+
balance["Net Debt"] = {p: balance["Total Debt"][p] - balance["Cash And Cash Equivalents"].get(p, 0)
|
|
247
|
+
for p in balance["Total Debt"]}
|
|
248
|
+
balance["Goodwill And Other Intangible Assets"] = combine(balance["Goodwill"], balance.pop("Other Intangible Assets"))
|
|
249
|
+
if cash["Operating Cash Flow"] and cash["Capital Expenditure"]:
|
|
250
|
+
cash["Free Cash Flow"] = combine(cash["Operating Cash Flow"], cash["Capital Expenditure"])
|
|
251
|
+
|
|
252
|
+
return _frame(income, periods), _frame(balance, periods), _frame(cash, periods)
|