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,226 @@
|
|
|
1
|
+
"""Ready-made crypto screens. Coins have no earnings or book value, so these
|
|
2
|
+
work on price and volume: momentum, strength against Bitcoin, oversold RSI,
|
|
3
|
+
breakouts, drawdowns, and liquidity.
|
|
4
|
+
|
|
5
|
+
Universe: the 100 most-traded USD pairs in our crypto listing (stablecoins
|
|
6
|
+
dropped), six months of daily closes from Binance's public klines (Yahoo for the
|
|
7
|
+
few coins Binance lacks; Yahoo alone misses many, e.g. SUI, UNI, PEPE).
|
|
8
|
+
Cached CACHE_SECONDS. Same result shape as marketmyna/services/screens.py so the Screens
|
|
9
|
+
card renders both.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import math
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import Callable, Optional
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
UNIVERSE = 100
|
|
25
|
+
CACHE_SECONDS = 3600
|
|
26
|
+
STABLES = {"USDT", "USDC", "DAI", "FDUSD", "TUSD", "USDE", "PYUSD", "BUSD", "USDD", "USDP", "GUSD", "EURC", "USD1", "RLUSD",
|
|
27
|
+
"PAXG", "XAUT", "EUR", "EURI", "AEUR"} # stablecoins and gold tokens: no crypto price action to screen
|
|
28
|
+
|
|
29
|
+
LABELS = {
|
|
30
|
+
"volume": "24h volume $", "ret_7d": "7d %", "ret_30d": "30d %", "ret_90d": "90d %", "vs_btc_30d": "vs BTC 30d pts",
|
|
31
|
+
"rsi14": "RSI 14", "from_high_90d": "From 90d high %", "volatility": "Volatility %/yr", "above_ma50": "Above 50d avg %",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# key: title, description, [(field, op, value)], (sort field, descending), columns
|
|
35
|
+
PRESETS = {
|
|
36
|
+
"volume_leaders": ("Most traded", "The most liquid coins by 24-hour dollar volume",
|
|
37
|
+
[], ("volume", True), ["volume", "ret_7d", "ret_30d", "volatility"]),
|
|
38
|
+
"momentum": ("Strong momentum", "Up over 10% in 30 days and trading above the 50-day average",
|
|
39
|
+
[("ret_30d", ">", 10), ("above_ma50", ">", 0)], ("ret_30d", True),
|
|
40
|
+
["ret_30d", "ret_7d", "above_ma50", "rsi14", "volume"]),
|
|
41
|
+
"beating_btc": ("Beating Bitcoin", "30-day return ahead of Bitcoin's",
|
|
42
|
+
[("vs_btc_30d", ">", 0)], ("vs_btc_30d", True), ["vs_btc_30d", "ret_30d", "ret_90d", "volume"]),
|
|
43
|
+
"oversold": ("Oversold (RSI < 35)", "Sold hard recently: 14-day RSI under 35",
|
|
44
|
+
[("rsi14", "<", 35)], ("rsi14", False), ["rsi14", "ret_7d", "ret_30d", "from_high_90d", "volume"]),
|
|
45
|
+
"near_high": ("Near 90-day high", "Within 5% of the 90-day closing high",
|
|
46
|
+
[("from_high_90d", ">", -5)], ("from_high_90d", True), ["from_high_90d", "ret_30d", "rsi14", "volume"]),
|
|
47
|
+
"drawdown": ("Deep drawdown", "More than 40% below the 90-day high",
|
|
48
|
+
[("from_high_90d", "<", -40)], ("from_high_90d", False), ["from_high_90d", "ret_90d", "rsi14", "volume"]),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Fetch = Callable[[list[str]], dict[str, pd.Series]]
|
|
52
|
+
|
|
53
|
+
_cache: dict[str, tuple[float, tuple[list[dict], dict]]] = {}
|
|
54
|
+
_lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _binance_one(symbol: str, days: int = 180) -> Optional[pd.Series]:
|
|
58
|
+
import requests
|
|
59
|
+
|
|
60
|
+
pair = symbol.split("-")[0] + "USDT"
|
|
61
|
+
r = requests.get("https://api.binance.com/api/v3/klines",
|
|
62
|
+
params={"symbol": pair, "interval": "1d", "limit": days}, timeout=15)
|
|
63
|
+
if r.status_code != 200:
|
|
64
|
+
return None
|
|
65
|
+
rows = r.json()
|
|
66
|
+
if not rows:
|
|
67
|
+
return None
|
|
68
|
+
return pd.Series([float(k[4]) for k in rows], index=pd.to_datetime([k[0] for k in rows], unit="ms"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fetch(tickers: list[str], days: int = 180) -> dict[str, pd.Series]:
|
|
72
|
+
"""Binance first (parallel), Yahoo for the rest."""
|
|
73
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
74
|
+
|
|
75
|
+
def one(t):
|
|
76
|
+
try:
|
|
77
|
+
return t, _binance_one(t, days)
|
|
78
|
+
except Exception:
|
|
79
|
+
return t, None
|
|
80
|
+
|
|
81
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
82
|
+
got = {t: s for t, s in pool.map(one, tickers) if s is not None and len(s) > 20}
|
|
83
|
+
missing = [t for t in tickers if t not in got]
|
|
84
|
+
if missing:
|
|
85
|
+
try:
|
|
86
|
+
got.update(_yahoo(missing))
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
logger.warning("Yahoo crypto history failed: %s", exc)
|
|
89
|
+
return got
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _yahoo(tickers: list[str]) -> dict[str, pd.Series]:
|
|
93
|
+
import yfinance as yf
|
|
94
|
+
|
|
95
|
+
from ..utils.yf_lock import YF_DOWNLOAD_LOCK
|
|
96
|
+
|
|
97
|
+
with YF_DOWNLOAD_LOCK:
|
|
98
|
+
df = yf.download(tickers, period="6mo", interval="1d", progress=False, auto_adjust=True, threads=True)
|
|
99
|
+
closes = df["Close"]
|
|
100
|
+
return {t: closes[t].dropna() for t in tickers if t in closes and closes[t].notna().sum()}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def universe(rows: list[dict]) -> list[dict]:
|
|
104
|
+
coins = [
|
|
105
|
+
r for r in rows
|
|
106
|
+
if r.get("symbol", "").endswith("-USD") and r["symbol"].split("-")[0] not in STABLES and (r.get("volume") or 0) > 0
|
|
107
|
+
]
|
|
108
|
+
if not any(r["symbol"] == "BTC-USD" for r in coins):
|
|
109
|
+
coins.append({"symbol": "BTC-USD", "name": "BTC", "volume": 0})
|
|
110
|
+
return sorted(coins, key=lambda r: -(r.get("volume") or 0))[:UNIVERSE]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _ret(s: pd.Series, days: int) -> Optional[float]:
|
|
114
|
+
if len(s) <= days:
|
|
115
|
+
return None
|
|
116
|
+
return float(s.iloc[-1] / s.iloc[-1 - days] - 1) * 100
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def rsi(s: pd.Series, n: int = 14) -> Optional[float]:
|
|
120
|
+
if len(s) <= n:
|
|
121
|
+
return None
|
|
122
|
+
d = s.diff().dropna()
|
|
123
|
+
gain = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean().iloc[-1]
|
|
124
|
+
loss = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean().iloc[-1]
|
|
125
|
+
if loss == 0:
|
|
126
|
+
return 100.0
|
|
127
|
+
return float(100 - 100 / (1 + gain / loss))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def metrics(s: pd.Series, btc_30d: Optional[float]) -> dict:
|
|
131
|
+
s = s.dropna()
|
|
132
|
+
out = {"ret_7d": _ret(s, 7), "ret_30d": _ret(s, 30), "ret_90d": _ret(s, 90), "rsi14": rsi(s)}
|
|
133
|
+
last90 = s.tail(90)
|
|
134
|
+
out["from_high_90d"] = float(s.iloc[-1] / last90.max() - 1) * 100 if len(last90) else None
|
|
135
|
+
ma50 = s.tail(50).mean() if len(s) >= 50 else None
|
|
136
|
+
out["above_ma50"] = float(s.iloc[-1] / ma50 - 1) * 100 if ma50 else None
|
|
137
|
+
daily = s.pct_change().dropna().tail(30)
|
|
138
|
+
out["volatility"] = float(daily.std() * math.sqrt(365) * 100) if len(daily) > 5 else None
|
|
139
|
+
out["vs_btc_30d"] = out["ret_30d"] - btc_30d if out["ret_30d"] is not None and btc_30d is not None else None
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _passes(m: dict, rules: list) -> bool:
|
|
144
|
+
for field, op, value in rules:
|
|
145
|
+
v = m.get(field)
|
|
146
|
+
if v is None or (op == ">" and not v > value) or (op == "<" and not v < value):
|
|
147
|
+
return False
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _rule_text(rule) -> str:
|
|
152
|
+
field, op, value = rule
|
|
153
|
+
return f"{LABELS.get(field, field)} {op} {value:g}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def list_presets() -> list[dict]:
|
|
157
|
+
return [{"key": k, "title": p[0], "description": p[1], "large_only": False, "rules": [_rule_text(r) for r in p[2]]}
|
|
158
|
+
for k, p in PRESETS.items()]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_history: dict[str, tuple[float, dict[str, pd.Series], set[str]]] = {}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def history(coins: list[dict], fetch: Fetch = _fetch, use_cache: bool = True) -> dict[str, pd.Series]:
|
|
165
|
+
"""Six months of daily closes for `coins`, cached CACHE_SECONDS (shared by
|
|
166
|
+
the screens and the crypto market mood)."""
|
|
167
|
+
wanted = [c["symbol"] for c in coins]
|
|
168
|
+
if use_cache:
|
|
169
|
+
with _lock:
|
|
170
|
+
hit = _history.get("all")
|
|
171
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS and set(wanted) <= hit[2]:
|
|
172
|
+
return hit[1]
|
|
173
|
+
closes = fetch(wanted)
|
|
174
|
+
if use_cache and closes:
|
|
175
|
+
with _lock:
|
|
176
|
+
_history["all"] = (time.time(), closes, set(wanted))
|
|
177
|
+
return closes
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _measured(rows: Optional[list[dict]], fetch: Fetch, use_cache: bool = False) -> tuple[list[dict], dict]:
|
|
181
|
+
if rows is None:
|
|
182
|
+
from ..data.listings import get_store
|
|
183
|
+
|
|
184
|
+
rows = get_store().rows("crypto")
|
|
185
|
+
coins = universe(rows)
|
|
186
|
+
closes = history(coins, fetch, use_cache)
|
|
187
|
+
btc_30d = _ret(closes["BTC-USD"], 30) if "BTC-USD" in closes else None
|
|
188
|
+
return coins, {sym: metrics(s, btc_30d) for sym, s in closes.items() if len(s) > 20}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run(key: str, fetch: Fetch = _fetch, rows: Optional[list[dict]] = None, use_cache: bool = True) -> dict:
|
|
192
|
+
if key not in PRESETS:
|
|
193
|
+
raise ValueError(f"Unknown screen '{key}'")
|
|
194
|
+
title, desc, rules, (sort_field, desc_sort), columns = PRESETS[key]
|
|
195
|
+
hit = None
|
|
196
|
+
if use_cache:
|
|
197
|
+
with _lock:
|
|
198
|
+
hit = _cache.get("all")
|
|
199
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
200
|
+
coins, ms = hit[1]
|
|
201
|
+
else:
|
|
202
|
+
coins, ms = _measured(rows, fetch, use_cache)
|
|
203
|
+
if use_cache and ms:
|
|
204
|
+
with _lock:
|
|
205
|
+
_cache["all"] = (time.time(), (coins, ms))
|
|
206
|
+
|
|
207
|
+
hits = []
|
|
208
|
+
for c in coins:
|
|
209
|
+
m = ms.get(c["symbol"])
|
|
210
|
+
if m is None:
|
|
211
|
+
continue
|
|
212
|
+
m = {**m, "volume": c.get("volume")}
|
|
213
|
+
if _passes(m, rules):
|
|
214
|
+
hits.append({"symbol": c["symbol"], "name": c.get("name"), "market": "crypto", "market_cap": None,
|
|
215
|
+
"volume": c.get("volume"), "price": c.get("price"), "currency": "USD",
|
|
216
|
+
"change_percent": c.get("change_percent"), "sector": None,
|
|
217
|
+
**{col: m.get(col) for col in columns}})
|
|
218
|
+
hits.sort(key=lambda h: (h.get(sort_field) is None, -(h.get(sort_field) or 0) if desc_sort else (h.get(sort_field) or 0)))
|
|
219
|
+
return {
|
|
220
|
+
"key": key, "title": title, "description": desc, "market": "crypto",
|
|
221
|
+
"rules": [_rule_text(r) for r in rules] or ["Ranked by 24h volume"],
|
|
222
|
+
"columns": [{"key": c, "label": LABELS.get(c, c)} for c in columns],
|
|
223
|
+
"universe": f"{UNIVERSE} most-traded coins (stablecoins excluded)",
|
|
224
|
+
"scanned": len(ms),
|
|
225
|
+
"results": hits[:50],
|
|
226
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Personal data export (GDPR art. 15 / 20, India DPDP Act s. 11): everything
|
|
2
|
+
the app stores about one user, as a single JSON document.
|
|
3
|
+
|
|
4
|
+
Walks the same tables account deletion does (every table with a ``user_id``
|
|
5
|
+
pointing at users, plus their child tables such as holdings, transactions and
|
|
6
|
+
watchlist items), so a table added later is exported without changes here.
|
|
7
|
+
Credentials are never exported: password and 2FA secrets, session and
|
|
8
|
+
one-time token hashes, encrypted API keys and push-subscription keys are left
|
|
9
|
+
out (see REDACT).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
from datetime import date, datetime
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
|
|
18
|
+
from sqlalchemy.orm import Session
|
|
19
|
+
|
|
20
|
+
from ..core import tenant
|
|
21
|
+
from ..storage import Base
|
|
22
|
+
|
|
23
|
+
# table -> columns never exported (secrets or security tokens, not personal data
|
|
24
|
+
# a person can use). Tables in SKIP_TABLES are left out entirely.
|
|
25
|
+
REDACT = {
|
|
26
|
+
"user_api_keys": {"key_ciphertext"},
|
|
27
|
+
"report_runs": {"token"},
|
|
28
|
+
"web_push_subscriptions": {"p256dh", "auth"},
|
|
29
|
+
}
|
|
30
|
+
SKIP_TABLES: set[str] = set()
|
|
31
|
+
FORMAT_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _plain(value):
|
|
35
|
+
if isinstance(value, (datetime, date)):
|
|
36
|
+
return value.isoformat()
|
|
37
|
+
if isinstance(value, Decimal):
|
|
38
|
+
return float(value)
|
|
39
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
40
|
+
return base64.b64encode(bytes(value)).decode()
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rows(session: Session, table, where) -> list[dict]:
|
|
45
|
+
hidden = REDACT.get(table.name, set())
|
|
46
|
+
cols = [c for c in table.columns if c.name not in hidden]
|
|
47
|
+
return [{c.name: _plain(row._mapping[c.name]) for c in cols}
|
|
48
|
+
for row in session.execute(table.select().where(where))]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def export_user(session: Session, user_id: int) -> dict:
|
|
52
|
+
"""{"tables": {table: [rows]}} for one user, credentials removed."""
|
|
53
|
+
tables: dict[str, list[dict]] = {}
|
|
54
|
+
with tenant.system_scope():
|
|
55
|
+
users = Base.metadata.tables["users"]
|
|
56
|
+
tables["users"] = _rows(session, users, users.c.id == user_id)
|
|
57
|
+
owned: dict[str, list] = {}
|
|
58
|
+
for table in Base.metadata.sorted_tables:
|
|
59
|
+
col = table.columns.get("user_id")
|
|
60
|
+
if col is None or table.name in SKIP_TABLES or table.name == "users":
|
|
61
|
+
continue
|
|
62
|
+
if not any(f.column.table.name == "users" for f in col.foreign_keys):
|
|
63
|
+
continue
|
|
64
|
+
rows = _rows(session, table, col == user_id)
|
|
65
|
+
tables[table.name] = rows
|
|
66
|
+
if "id" in table.columns:
|
|
67
|
+
owned[table.name] = [r["id"] for r in rows if r.get("id") is not None]
|
|
68
|
+
# Child tables: rows that point at something the user owns (holdings,
|
|
69
|
+
# transactions, watchlist items) but carry no user_id of their own.
|
|
70
|
+
for table in Base.metadata.sorted_tables:
|
|
71
|
+
if table.name in tables or table.name in SKIP_TABLES or "user_id" in table.columns:
|
|
72
|
+
continue
|
|
73
|
+
for col in table.columns:
|
|
74
|
+
parent = next((f.column.table.name for f in col.foreign_keys if f.column.table.name in owned), None)
|
|
75
|
+
if parent and owned[parent]:
|
|
76
|
+
tables[table.name] = _rows(session, table, col.in_(owned[parent]))
|
|
77
|
+
break
|
|
78
|
+
return {
|
|
79
|
+
"format": "marketmyna-personal-data",
|
|
80
|
+
"version": FORMAT_VERSION,
|
|
81
|
+
"exported_at": datetime.now().isoformat(timespec="seconds"),
|
|
82
|
+
"user_id": user_id,
|
|
83
|
+
"notes": [
|
|
84
|
+
"Everything MarketMyna stores about your account, one list per table.",
|
|
85
|
+
"Left out on purpose: your password and 2-factor secrets, login and email-link tokens, "
|
|
86
|
+
"your saved AI keys and webhook links (stored encrypted) "
|
|
87
|
+
"and the keys of your browser push subscriptions.",
|
|
88
|
+
"Market data (prices, fund NAVs, listings) is public data, not personal data, and is not included.",
|
|
89
|
+
],
|
|
90
|
+
"tables": {k: v for k, v in tables.items() if v},
|
|
91
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Discounted cash flow, reverse DCF and the Graham number for one stock.
|
|
2
|
+
|
|
3
|
+
DCF: free cash flow (operating cash flow minus capital expenditure and
|
|
4
|
+
stock-based compensation, latest annual filing) grows at the chosen rate for
|
|
5
|
+
5 years, fades in a straight line to the terminal rate over the next 5, then
|
|
6
|
+
grows at the terminal rate for ever (Gordon growth). Each year is discounted
|
|
7
|
+
at the chosen rate; net cash (cash, short-term and long-term investments,
|
|
8
|
+
minus debt) is added and the total divided by the share count.
|
|
9
|
+
|
|
10
|
+
Stock-based compensation is a real cost paid in shares instead of cash, so
|
|
11
|
+
it is taken off; leaving it in flatters companies that pay staff in stock.
|
|
12
|
+
|
|
13
|
+
Reverse DCF: the growth rate in years 1-5 that makes the model value equal
|
|
14
|
+
today's price, with the same discount and terminal rates. It answers "what
|
|
15
|
+
does the price assume?" rather than "what is it worth?".
|
|
16
|
+
|
|
17
|
+
Graham number: sqrt(22.5 x EPS x book value per share), Benjamin Graham's
|
|
18
|
+
ceiling for a defensive investor (P/E 15 x P/B 1.5).
|
|
19
|
+
|
|
20
|
+
The page lets the viewer change growth, discount and terminal rates and
|
|
21
|
+
recomputes the same formula in the browser (frontend/src/lib/dcf.ts).
|
|
22
|
+
Defaults are plain starting points, not forecasts. Banks and insurers get
|
|
23
|
+
no DCF (their cash flow statement mixes in lending), only the Graham number.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import math
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
HIGH_YEARS, TOTAL_YEARS = 5, 10
|
|
37
|
+
CACHE_SECONDS = 6 * 3600
|
|
38
|
+
|
|
39
|
+
# currency -> (discount rate, terminal growth): long-run government yield plus
|
|
40
|
+
# an equity risk premium, terminal growth near long-run nominal GDP growth
|
|
41
|
+
DEFAULTS = {"INR": (0.12, 0.05), "USD": (0.09, 0.025)}
|
|
42
|
+
OTHER = (0.10, 0.03)
|
|
43
|
+
|
|
44
|
+
_cache: dict[tuple[str, str], tuple[float, dict]] = {}
|
|
45
|
+
_lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def model_value(fcf: float, growth: float, discount: float, terminal: float,
|
|
49
|
+
net_cash: float, shares: float) -> Optional[float]:
|
|
50
|
+
"""Per-share value; None when discount <= terminal (no finite answer)."""
|
|
51
|
+
if discount <= terminal or shares <= 0:
|
|
52
|
+
return None
|
|
53
|
+
pv, cash = 0.0, fcf
|
|
54
|
+
for year in range(1, TOTAL_YEARS + 1):
|
|
55
|
+
if year <= HIGH_YEARS:
|
|
56
|
+
g = growth
|
|
57
|
+
else: # straight-line fade from the high-growth rate to the terminal rate
|
|
58
|
+
g = growth + (terminal - growth) * (year - HIGH_YEARS) / (TOTAL_YEARS - HIGH_YEARS)
|
|
59
|
+
cash *= 1 + g
|
|
60
|
+
pv += cash / (1 + discount) ** year
|
|
61
|
+
tv = cash * (1 + terminal) / (discount - terminal)
|
|
62
|
+
pv += tv / (1 + discount) ** TOTAL_YEARS
|
|
63
|
+
return (pv + net_cash) / shares
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def implied_growth(price: float, fcf: float, discount: float, terminal: float,
|
|
67
|
+
net_cash: float, shares: float) -> Optional[float]:
|
|
68
|
+
"""Years 1-5 growth that the price implies (bisection), None if outside -50%..+100%."""
|
|
69
|
+
lo, hi = -0.5, 1.0
|
|
70
|
+
|
|
71
|
+
def gap(g):
|
|
72
|
+
v = model_value(fcf, g, discount, terminal, net_cash, shares)
|
|
73
|
+
return None if v is None else v - price
|
|
74
|
+
|
|
75
|
+
a, b = gap(lo), gap(hi)
|
|
76
|
+
if a is None or b is None or a > 0 or b < 0:
|
|
77
|
+
return None
|
|
78
|
+
for _ in range(80):
|
|
79
|
+
mid = (lo + hi) / 2
|
|
80
|
+
if gap(mid) < 0:
|
|
81
|
+
lo = mid
|
|
82
|
+
else:
|
|
83
|
+
hi = mid
|
|
84
|
+
return (lo + hi) / 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def graham_number(eps: Optional[float], bvps: Optional[float]) -> Optional[float]:
|
|
88
|
+
if not eps or not bvps or eps <= 0 or bvps <= 0:
|
|
89
|
+
return None
|
|
90
|
+
return math.sqrt(22.5 * eps * bvps)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cagr(newest: Optional[float], oldest: Optional[float], years: int) -> Optional[float]:
|
|
94
|
+
if not newest or not oldest or newest <= 0 or oldest <= 0 or years <= 0:
|
|
95
|
+
return None
|
|
96
|
+
return (newest / oldest) ** (1 / years) - 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _row(statement: dict, key: str) -> list[Optional[float]]:
|
|
100
|
+
return next((r["values"] for r in statement.get("rows", []) if r["key"] == key), [])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fx(from_ccy: str, to_ccy: str) -> Optional[float]:
|
|
104
|
+
"""Units of to_ccy per from_ccy: Yahoo's pair, else the ECB reference
|
|
105
|
+
rate (the ECB has no TWD, SAR and a few others)."""
|
|
106
|
+
from ..data import ecb_fx
|
|
107
|
+
from ..data.yahoo_price import info_price
|
|
108
|
+
from .compare import _yahoo_info
|
|
109
|
+
|
|
110
|
+
pair = f"{from_ccy}{to_ccy}=X"
|
|
111
|
+
try:
|
|
112
|
+
rate = info_price(_yahoo_info(pair))
|
|
113
|
+
if rate and rate > 0:
|
|
114
|
+
return float(rate)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.info("Yahoo FX %s for DCF failed: %s", pair, exc)
|
|
117
|
+
try:
|
|
118
|
+
s = ecb_fx.series([pair], days=14).get(pair)
|
|
119
|
+
return float(s.iloc[-1]) if s is not None and len(s) else None
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
logger.info("FX %s for DCF failed: %s", pair, exc)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build(symbol: str, market: str, *, financials: Optional[dict] = None, info: Optional[dict] = None,
|
|
126
|
+
use_cache: bool = True) -> dict:
|
|
127
|
+
from ..data.yahoo_price import info_price
|
|
128
|
+
from .analysis_run import yahoo_symbol
|
|
129
|
+
from .compare import _yahoo_info
|
|
130
|
+
|
|
131
|
+
if market == "crypto":
|
|
132
|
+
raise ValueError("A DCF does not apply to crypto")
|
|
133
|
+
key = (symbol, market)
|
|
134
|
+
if use_cache and financials is None:
|
|
135
|
+
with _lock:
|
|
136
|
+
hit = _cache.get(key)
|
|
137
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
138
|
+
return hit[1]
|
|
139
|
+
|
|
140
|
+
if info is None:
|
|
141
|
+
info = _yahoo_info(yahoo_symbol(symbol, market))
|
|
142
|
+
if financials is None:
|
|
143
|
+
from .financials import cached_financials
|
|
144
|
+
|
|
145
|
+
financials = cached_financials(symbol, market, "annual")
|
|
146
|
+
|
|
147
|
+
price = info_price(info)
|
|
148
|
+
price_ccy = info.get("currency") or financials.get("currency")
|
|
149
|
+
fin_ccy = financials.get("currency") or info.get("financialCurrency") or price_ccy
|
|
150
|
+
rate = 1.0
|
|
151
|
+
if fin_ccy and price_ccy and fin_ccy != price_ccy:
|
|
152
|
+
rate = _fx(fin_ccy, price_ccy) or 0.0
|
|
153
|
+
discount, terminal = DEFAULTS.get(price_ccy or "", OTHER)
|
|
154
|
+
|
|
155
|
+
out: dict = {
|
|
156
|
+
"symbol": symbol, "market": market, "name": financials.get("name") or symbol,
|
|
157
|
+
"currency": price_ccy, "price": price, "periods": financials.get("periods", []),
|
|
158
|
+
"dcf": None, "graham": None, "note": None,
|
|
159
|
+
"source": f"Annual figures: {financials.get('source') or 'reported financial statements'}; price: market data feed",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Graham number on Yahoo's trailing EPS and book value per share (same currency as the price
|
|
163
|
+
# only when the filings are in that currency)
|
|
164
|
+
if fin_ccy == price_ccy:
|
|
165
|
+
eps, bvps = info.get("trailingEps"), info.get("bookValue")
|
|
166
|
+
g = graham_number(eps, bvps)
|
|
167
|
+
if g:
|
|
168
|
+
out["graham"] = {"value": round(g, 2), "eps": eps, "book_value_per_share": bvps,
|
|
169
|
+
"vs_price_pct": round((price / g - 1) * 100, 1) if price else None}
|
|
170
|
+
|
|
171
|
+
if financials.get("financial_sector"):
|
|
172
|
+
out["note"] = "No DCF for banks and insurers: their cash flow statement mixes lending in with operations."
|
|
173
|
+
else:
|
|
174
|
+
ocf, capex = _row(financials["cashflow"], "operating_cf"), _row(financials["cashflow"], "capex")
|
|
175
|
+
sbc = _row(financials["cashflow"], "sbc")
|
|
176
|
+
sbc = sbc + [None] * (len(ocf) - len(sbc))
|
|
177
|
+
# Not reported (many Indian filers): counted as zero.
|
|
178
|
+
fcf_hist = [o + c - (s or 0.0) if o is not None and c is not None else None for o, c, s in zip(ocf, capex, sbc)]
|
|
179
|
+
cash = next((v for v in _row(financials["balance"], "cash") if v is not None), 0.0)
|
|
180
|
+
long_term = next((v for v in _row(financials["balance"], "long_term_investments") if v is not None), 0.0)
|
|
181
|
+
debt = next((v for v in _row(financials["balance"], "total_debt") if v is not None), 0.0)
|
|
182
|
+
shares = info.get("sharesOutstanding") or next((v for v in _row(financials["balance"], "shares") if v), None)
|
|
183
|
+
recent = [v for v in fcf_hist[:3] if v is not None]
|
|
184
|
+
base = fcf_hist[0] if fcf_hist and fcf_hist[0] and fcf_hist[0] > 0 else None
|
|
185
|
+
base_label = "latest year"
|
|
186
|
+
if base is None and recent and sum(recent) / len(recent) > 0:
|
|
187
|
+
base, base_label = sum(recent) / len(recent), f"{len(recent)}-year average (latest year negative)"
|
|
188
|
+
# Default growth: revenue growth over the filings shown (steadier than free
|
|
189
|
+
# cash flow, which swings with capex), else free cash flow growth
|
|
190
|
+
rev = [v for v in _row(financials["income"], "revenue") if v is not None]
|
|
191
|
+
growth = _cagr(rev[0], rev[-1], len(rev) - 1) if len(rev) >= 3 else None
|
|
192
|
+
known = [(i, v) for i, v in enumerate(fcf_hist) if v is not None]
|
|
193
|
+
if growth is None and len(known) >= 3:
|
|
194
|
+
growth = _cagr(known[0][1], known[-1][1], known[-1][0] - known[0][0])
|
|
195
|
+
growth = min(max(growth if growth is not None else 0.06, 0.0), 0.15)
|
|
196
|
+
|
|
197
|
+
if base is None or not shares or not price or not rate:
|
|
198
|
+
out["note"] = ("No DCF: free cash flow has been negative" if base is None
|
|
199
|
+
else "No DCF: share count, price or currency conversion unavailable")
|
|
200
|
+
else:
|
|
201
|
+
base, net_cash = base * rate, (cash + long_term - debt) * rate
|
|
202
|
+
value = model_value(base, growth, discount, terminal, net_cash, shares)
|
|
203
|
+
implied = implied_growth(price, base, discount, terminal, net_cash, shares)
|
|
204
|
+
out["dcf"] = {
|
|
205
|
+
"base_fcf": base, "base_label": base_label, "net_cash": net_cash, "shares": shares,
|
|
206
|
+
"sbc": sbc[0] * rate if sbc and sbc[0] is not None else None,
|
|
207
|
+
"long_term_investments": long_term * rate,
|
|
208
|
+
"fcf_history": [v * rate if v is not None else None for v in fcf_hist],
|
|
209
|
+
"converted_from": fin_ccy if rate != 1.0 else None,
|
|
210
|
+
"defaults": {"growth": round(growth, 4), "discount": discount, "terminal": terminal,
|
|
211
|
+
"high_years": HIGH_YEARS, "total_years": TOTAL_YEARS},
|
|
212
|
+
"value": round(value, 2) if value is not None else None,
|
|
213
|
+
"vs_price_pct": round((price / value - 1) * 100, 1) if value and value > 0 else None,
|
|
214
|
+
"implied_growth": round(implied, 4) if implied is not None else None,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if use_cache:
|
|
218
|
+
with _lock:
|
|
219
|
+
_cache[key] = (time.time(), out)
|
|
220
|
+
return out
|