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,328 @@
|
|
|
1
|
+
"""Thesis tracker: why you own (or watch) a stock, kept honest against data.
|
|
2
|
+
|
|
3
|
+
Adapted from the thesis-tracker skill in anthropics/financial-services
|
|
4
|
+
(Apache-2.0). A thesis should be falsifiable, so it carries:
|
|
5
|
+
|
|
6
|
+
statement 1-2 sentences ("margins expand as the mix shifts to software")
|
|
7
|
+
pillars up to 5 supporting points, each on track / watch / broken
|
|
8
|
+
risks up to 5 things that would invalidate it
|
|
9
|
+
exit_below the price at which the thesis is broken; exit_note for the rest
|
|
10
|
+
target what it is worth if the thesis plays out
|
|
11
|
+
conviction high / medium / low
|
|
12
|
+
log dated updates: what happened and whether it strengthened or
|
|
13
|
+
weakened the thesis - disconfirming evidence included
|
|
14
|
+
|
|
15
|
+
The data check sets the thesis against live numbers: price vs the exit and
|
|
16
|
+
target levels, the next results date, estimate revisions, pillars marked
|
|
17
|
+
broken and whether the quarterly review is overdue. All of it is the user's
|
|
18
|
+
own view measured against arithmetic - nothing here is a recommendation.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
from datetime import date, datetime
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
STATUSES = ("on_track", "watch", "broken")
|
|
31
|
+
IMPACTS = ("strengthens", "weakens", "neutral")
|
|
32
|
+
CONVICTIONS = ("high", "medium", "low")
|
|
33
|
+
MAX_ITEMS = 5
|
|
34
|
+
MAX_LOG = 200
|
|
35
|
+
REVIEW_DAYS = 90 # review at least quarterly, even when nothing happens
|
|
36
|
+
REARM = 0.03 # an exit / target alert re-arms once the price is 3% back on the other side
|
|
37
|
+
ALERT_EVERY = 30 * 60 # price checks per user at most this often
|
|
38
|
+
|
|
39
|
+
_last_run: dict[Optional[int], float] = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ThesisError(ValueError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _db(config):
|
|
47
|
+
from ..storage import DatabaseManager, get_db_url
|
|
48
|
+
|
|
49
|
+
return DatabaseManager.instance(get_db_url(config=config))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_dict(r) -> dict:
|
|
53
|
+
return {"id": r.id, "symbol": r.symbol, "market": r.market, "statement": r.statement,
|
|
54
|
+
"pillars": r.pillars or [], "risks": r.risks or [], "exit_below": r.exit_below,
|
|
55
|
+
"exit_note": r.exit_note or "", "target": r.target, "conviction": r.conviction or "medium",
|
|
56
|
+
"log": r.log or [], "created_at": r.created_at.date().isoformat() if r.created_at else None,
|
|
57
|
+
"reviewed_at": r.reviewed_at, "alerted": r.alerted or {}}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clean(fields: dict) -> dict:
|
|
61
|
+
out = {}
|
|
62
|
+
if "statement" in fields:
|
|
63
|
+
text = (fields["statement"] or "").strip()
|
|
64
|
+
if not text:
|
|
65
|
+
raise ThesisError("Write the thesis in a sentence or two")
|
|
66
|
+
out["statement"] = text[:2000]
|
|
67
|
+
if "pillars" in fields:
|
|
68
|
+
pillars = []
|
|
69
|
+
for p in (fields["pillars"] or [])[:MAX_ITEMS]:
|
|
70
|
+
text = str((p or {}).get("text", "")).strip()[:300]
|
|
71
|
+
if text:
|
|
72
|
+
status = (p or {}).get("status") or "on_track"
|
|
73
|
+
if status not in STATUSES:
|
|
74
|
+
raise ThesisError(f"Unknown pillar status '{status}'")
|
|
75
|
+
pillars.append({"text": text, "status": status})
|
|
76
|
+
out["pillars"] = pillars
|
|
77
|
+
if "risks" in fields:
|
|
78
|
+
out["risks"] = [str(r).strip()[:300] for r in (fields["risks"] or [])[:MAX_ITEMS] if str(r).strip()]
|
|
79
|
+
for key in ("exit_below", "target"):
|
|
80
|
+
if key in fields:
|
|
81
|
+
v = fields[key]
|
|
82
|
+
if v is not None and (not isinstance(v, (int, float)) or v <= 0):
|
|
83
|
+
raise ThesisError(f"{key.replace('_', ' ').capitalize()} must be a price above 0")
|
|
84
|
+
out[key] = float(v) if v is not None else None
|
|
85
|
+
if out.get("exit_below") and out.get("target") and out["exit_below"] >= out["target"]:
|
|
86
|
+
raise ThesisError("The exit price should be below the target")
|
|
87
|
+
if "exit_note" in fields:
|
|
88
|
+
out["exit_note"] = (fields["exit_note"] or "").strip()[:500]
|
|
89
|
+
if "conviction" in fields:
|
|
90
|
+
if fields["conviction"] not in CONVICTIONS:
|
|
91
|
+
raise ThesisError("Conviction is high, medium or low")
|
|
92
|
+
out["conviction"] = fields["conviction"]
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get(config, symbol: str, market: str) -> Optional[dict]:
|
|
97
|
+
from ..storage import ThesisRecord
|
|
98
|
+
|
|
99
|
+
with _db(config).get_session() as s:
|
|
100
|
+
r = s.query(ThesisRecord).filter(ThesisRecord.symbol == symbol, ThesisRecord.market == market).first()
|
|
101
|
+
return to_dict(r) if r else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def list_all(config) -> list[dict]:
|
|
105
|
+
from ..storage import ThesisRecord
|
|
106
|
+
|
|
107
|
+
with _db(config).get_session() as s:
|
|
108
|
+
return [to_dict(r) for r in s.query(ThesisRecord).order_by(ThesisRecord.symbol)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def save(config, symbol: str, market: str, **fields) -> dict:
|
|
112
|
+
"""Create or update the thesis for one stock."""
|
|
113
|
+
from ..storage import ThesisRecord
|
|
114
|
+
|
|
115
|
+
clean = _clean(fields)
|
|
116
|
+
with _db(config).get_session() as s:
|
|
117
|
+
r = s.query(ThesisRecord).filter(ThesisRecord.symbol == symbol, ThesisRecord.market == market).first()
|
|
118
|
+
if r is None:
|
|
119
|
+
if "statement" not in clean:
|
|
120
|
+
raise ThesisError("Write the thesis in a sentence or two")
|
|
121
|
+
r = ThesisRecord(symbol=symbol, market=market, conviction="medium", created_at=datetime.now(),
|
|
122
|
+
reviewed_at=date.today().isoformat(), log=[])
|
|
123
|
+
s.add(r)
|
|
124
|
+
changed_levels = {"exit_below", "target"} & set(clean)
|
|
125
|
+
for k, v in clean.items():
|
|
126
|
+
setattr(r, k, v)
|
|
127
|
+
if changed_levels: # new levels: their alerts may fire again
|
|
128
|
+
r.alerted = {k: v for k, v in (r.alerted or {}).items() if k not in ("exit", "target")}
|
|
129
|
+
s.flush()
|
|
130
|
+
return to_dict(r)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_log(config, thesis_id: int, note: str, impact: str, conviction: Optional[str] = None,
|
|
134
|
+
day: Optional[str] = None) -> Optional[dict]:
|
|
135
|
+
"""A dated update; adding one counts as a review."""
|
|
136
|
+
from ..storage import ThesisRecord
|
|
137
|
+
|
|
138
|
+
note = (note or "").strip()[:1000]
|
|
139
|
+
if not note:
|
|
140
|
+
raise ThesisError("Write what happened")
|
|
141
|
+
if impact not in IMPACTS:
|
|
142
|
+
raise ThesisError("Impact is strengthens, weakens or neutral")
|
|
143
|
+
if conviction is not None and conviction not in CONVICTIONS:
|
|
144
|
+
raise ThesisError("Conviction is high, medium or low")
|
|
145
|
+
with _db(config).get_session() as s:
|
|
146
|
+
r = s.query(ThesisRecord).filter(ThesisRecord.id == thesis_id).first()
|
|
147
|
+
if r is None:
|
|
148
|
+
return None
|
|
149
|
+
today = day or date.today().isoformat()
|
|
150
|
+
entry = {"date": today, "note": note, "impact": impact, "conviction": conviction or r.conviction}
|
|
151
|
+
r.log = ([entry] + list(r.log or []))[:MAX_LOG] # newest first
|
|
152
|
+
if conviction:
|
|
153
|
+
r.conviction = conviction
|
|
154
|
+
r.reviewed_at = today
|
|
155
|
+
r.alerted = {k: v for k, v in (r.alerted or {}).items() if k != "review"}
|
|
156
|
+
s.flush()
|
|
157
|
+
return to_dict(r)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def mark_reviewed(config, thesis_id: int, day: Optional[str] = None) -> Optional[dict]:
|
|
161
|
+
from ..storage import ThesisRecord
|
|
162
|
+
|
|
163
|
+
with _db(config).get_session() as s:
|
|
164
|
+
r = s.query(ThesisRecord).filter(ThesisRecord.id == thesis_id).first()
|
|
165
|
+
if r is None:
|
|
166
|
+
return None
|
|
167
|
+
r.reviewed_at = day or date.today().isoformat()
|
|
168
|
+
r.alerted = {k: v for k, v in (r.alerted or {}).items() if k != "review"}
|
|
169
|
+
s.flush()
|
|
170
|
+
return to_dict(r)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def delete(config, thesis_id: int) -> bool:
|
|
174
|
+
from ..storage import ThesisRecord
|
|
175
|
+
|
|
176
|
+
with _db(config).get_session() as s:
|
|
177
|
+
return bool(s.query(ThesisRecord).filter(ThesisRecord.id == thesis_id).delete())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------ data check
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def review_due(t: dict, today: date) -> Optional[int]:
|
|
184
|
+
"""Days since the last review when it is overdue, else None."""
|
|
185
|
+
last = t.get("reviewed_at") or t.get("created_at")
|
|
186
|
+
if not last:
|
|
187
|
+
return None
|
|
188
|
+
days = (today - date.fromisoformat(last)).days
|
|
189
|
+
return days if days >= REVIEW_DAYS else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check(t: dict, price: Optional[float], earnings: Optional[dict] = None, today: Optional[date] = None) -> list[dict]:
|
|
193
|
+
"""The thesis against live numbers: [{tone: up | down | warn | info, text}]."""
|
|
194
|
+
today = today or date.today()
|
|
195
|
+
out = []
|
|
196
|
+
if price:
|
|
197
|
+
if t.get("exit_below"):
|
|
198
|
+
gap = (price / t["exit_below"] - 1) * 100
|
|
199
|
+
if price <= t["exit_below"]:
|
|
200
|
+
out.append({"tone": "down", "key": "exit",
|
|
201
|
+
"text": f"Price {price:,.2f} is at or below your exit level {t['exit_below']:,.2f}: "
|
|
202
|
+
"by your own rule the thesis is broken."})
|
|
203
|
+
else:
|
|
204
|
+
out.append({"tone": "info", "key": "exit",
|
|
205
|
+
"text": f"{gap:.1f}% above your exit level {t['exit_below']:,.2f}."})
|
|
206
|
+
if t.get("target"):
|
|
207
|
+
if price >= t["target"]:
|
|
208
|
+
out.append({"tone": "up", "key": "target",
|
|
209
|
+
"text": f"Price {price:,.2f} has reached your target {t['target']:,.2f}: time to "
|
|
210
|
+
"decide whether the thesis still has room to run."})
|
|
211
|
+
else:
|
|
212
|
+
out.append({"tone": "info", "key": "target",
|
|
213
|
+
"text": f"Your target {t['target']:,.2f} is {(t['target'] / price - 1) * 100:.1f}% above the price."})
|
|
214
|
+
pillars = t.get("pillars") or []
|
|
215
|
+
broken = [p for p in pillars if p["status"] == "broken"]
|
|
216
|
+
watch = [p for p in pillars if p["status"] == "watch"]
|
|
217
|
+
if broken:
|
|
218
|
+
out.append({"tone": "down", "key": "pillars",
|
|
219
|
+
"text": f"{len(broken)} of {len(pillars)} pillars marked broken: is the thesis still intact?"})
|
|
220
|
+
elif watch:
|
|
221
|
+
out.append({"tone": "warn", "key": "pillars", "text": f"{len(watch)} of {len(pillars)} pillars on watch."})
|
|
222
|
+
weak = [e for e in (t.get("log") or [])[:3] if e.get("impact") == "weakens"]
|
|
223
|
+
if len(weak) >= 2:
|
|
224
|
+
out.append({"tone": "warn", "key": "log",
|
|
225
|
+
"text": f"{len(weak)} of your last 3 updates weakened the thesis."})
|
|
226
|
+
due = review_due(t, today)
|
|
227
|
+
if due is not None:
|
|
228
|
+
out.append({"tone": "warn", "key": "review",
|
|
229
|
+
"text": f"Last reviewed {due} days ago: review at least quarterly, even when nothing happened."})
|
|
230
|
+
if earnings:
|
|
231
|
+
nxt, days = earnings.get("next"), earnings.get("days_to_next")
|
|
232
|
+
if nxt and days is not None and 0 <= days <= 21:
|
|
233
|
+
out.append({"tone": "info", "key": "earnings",
|
|
234
|
+
"text": f"Results on {nxt['date']} ({'today' if days == 0 else f'in {days} days'}): "
|
|
235
|
+
"a fresh data point for each pillar."})
|
|
236
|
+
latest = earnings.get("latest")
|
|
237
|
+
if latest and latest.get("actual") is not None and latest.get("surprise") is not None:
|
|
238
|
+
word = "beat" if latest["surprise"] > 0 else "missed"
|
|
239
|
+
out.append({"tone": "info", "key": "results",
|
|
240
|
+
"text": f"Just reported: EPS {word} estimates by {abs(latest['surprise']):.1f}%. "
|
|
241
|
+
"Log what it means for the thesis."})
|
|
242
|
+
momentum = ((earnings.get("consensus") or {}).get("momentum") or {}).get("label")
|
|
243
|
+
if momentum == "falling":
|
|
244
|
+
out.append({"tone": "warn", "key": "estimates", "text": "Analysts' estimates are falling (90 days)."})
|
|
245
|
+
elif momentum == "rising":
|
|
246
|
+
out.append({"tone": "up", "key": "estimates", "text": "Analysts' estimates are rising (90 days)."})
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _price(symbol: str, market: str) -> Optional[float]:
|
|
251
|
+
from ..agent.agents import _resolve_fetcher
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
return float((_resolve_fetcher(market).get_quote(symbol) or {}).get("price") or 0) or None
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
logger.info("Thesis price for %s unavailable: %s", symbol, exc)
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _earnings(symbol: str, market: str) -> Optional[dict]:
|
|
261
|
+
from .earnings import build
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
return build(symbol, market, with_options=False)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.info("Thesis earnings for %s unavailable: %s", symbol, exc)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def with_check(t: dict, price: Optional[Callable] = None, earnings: Optional[Callable] = None) -> dict:
|
|
271
|
+
p = (price or _price)(t["symbol"], t["market"])
|
|
272
|
+
e = (earnings or _earnings)(t["symbol"], t["market"]) if t["market"] != "crypto" else None
|
|
273
|
+
return {**t, "price": p, "signals": check(t, p, e)}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ alerts
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_alerts(config, dispatcher=None, today: Optional[date] = None, price: Optional[Callable] = None,
|
|
280
|
+
force: bool = False) -> list[str]:
|
|
281
|
+
"""Monitor hook: one message per thesis when the price crosses the exit or
|
|
282
|
+
target level, or the quarterly review falls due. Re-arms after the price
|
|
283
|
+
moves back 3%; a review reminder repeats monthly until reviewed."""
|
|
284
|
+
from ..core import tenant
|
|
285
|
+
from ..storage import ThesisRecord
|
|
286
|
+
|
|
287
|
+
uid = tenant.current_user_id()
|
|
288
|
+
clock = time.time()
|
|
289
|
+
if not force and clock - _last_run.get(uid, 0) < ALERT_EVERY:
|
|
290
|
+
return []
|
|
291
|
+
_last_run[uid] = clock
|
|
292
|
+
today = today or date.today()
|
|
293
|
+
price = price or _price
|
|
294
|
+
lines = []
|
|
295
|
+
with _db(config).get_session() as s:
|
|
296
|
+
for r in s.query(ThesisRecord).all():
|
|
297
|
+
t, alerted, changed = to_dict(r), dict(r.alerted or {}), False
|
|
298
|
+
p = price(r.symbol, r.market) if (r.exit_below or r.target) else None
|
|
299
|
+
if p and r.exit_below:
|
|
300
|
+
if p <= r.exit_below and "exit" not in alerted:
|
|
301
|
+
lines.append(f"• {r.symbol}: price {p:,.2f} is at or below your exit level {r.exit_below:,.2f}")
|
|
302
|
+
alerted["exit"], changed = today.isoformat(), True
|
|
303
|
+
elif p > r.exit_below * (1 + REARM) and "exit" in alerted:
|
|
304
|
+
alerted.pop("exit")
|
|
305
|
+
changed = True
|
|
306
|
+
if p and r.target:
|
|
307
|
+
if p >= r.target and "target" not in alerted:
|
|
308
|
+
lines.append(f"• {r.symbol}: price {p:,.2f} reached your target {r.target:,.2f}")
|
|
309
|
+
alerted["target"], changed = today.isoformat(), True
|
|
310
|
+
elif p < r.target * (1 - REARM) and "target" in alerted:
|
|
311
|
+
alerted.pop("target")
|
|
312
|
+
changed = True
|
|
313
|
+
due = review_due(t, today)
|
|
314
|
+
last = alerted.get("review")
|
|
315
|
+
if due is not None and (not last or (today - date.fromisoformat(last)).days >= 30):
|
|
316
|
+
lines.append(f"• {r.symbol}: thesis last reviewed {due} days ago - time for a quarterly review")
|
|
317
|
+
alerted["review"], changed = today.isoformat(), True
|
|
318
|
+
if changed:
|
|
319
|
+
r.alerted = alerted
|
|
320
|
+
if lines:
|
|
321
|
+
try:
|
|
322
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
323
|
+
|
|
324
|
+
dispatcher = dispatcher or NotificationDispatcher(config)
|
|
325
|
+
dispatcher.send_all("📝 Your theses\n" + "\n".join(lines), title="Thesis check", purpose="alert")
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
logger.error("Thesis alert failed: %s", exc)
|
|
328
|
+
return lines
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""English for the Chinese names China A data comes with (opt-in on the page).
|
|
2
|
+
|
|
3
|
+
No AI needed:
|
|
4
|
+
- Stock names: the company's own English name from Yahoo ("宁德时代" ->
|
|
5
|
+
"Contemporary Amperex Technology"), fetched only for the symbols on
|
|
6
|
+
screen and saved for good in ~/.marketmyna/cache/english_names.json.
|
|
7
|
+
Beijing-exchange stocks Yahoo lacks fall back to pinyin.
|
|
8
|
+
- Free text (block-trade broker branches, ...): a glossary of brokers and
|
|
9
|
+
standard terms, the rest romanised with pypinyin ("上海浦东新区" ->
|
|
10
|
+
"Shanghai Pudong Xinqu").
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import threading
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Optional
|
|
22
|
+
|
|
23
|
+
from marketmyna.core.home import app_home
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
CJK = re.compile(r"[㐀-鿿]")
|
|
28
|
+
MAX_LOOKUPS = 80 # per request; the rest come on the next scroll / page
|
|
29
|
+
|
|
30
|
+
# Longest first when applied. Brokers people will recognise, then the
|
|
31
|
+
# boilerplate every branch name carries.
|
|
32
|
+
GLOSSARY = {
|
|
33
|
+
"机构专用": "Institutional seat",
|
|
34
|
+
"沪股通专用": "Northbound (Shanghai Connect)",
|
|
35
|
+
"深股通专用": "Northbound (Shenzhen Connect)",
|
|
36
|
+
"中国国际金融": "CICC", "中信证券": "CITIC Securities", "中信建投": "CSC Financial", "华泰证券": "Huatai Securities",
|
|
37
|
+
"国泰君安": "Guotai Junan", "国泰海通": "Guotai Haitong", "海通证券": "Haitong Securities", "招商证券": "China Merchants Securities",
|
|
38
|
+
"广发证券": "GF Securities", "银河证券": "China Galaxy Securities", "中国银河": "China Galaxy", "申万宏源": "Shenwan Hongyuan",
|
|
39
|
+
"东方财富": "East Money", "国信证券": "Guosen Securities", "光大证券": "Everbright Securities", "兴业证券": "Industrial Securities",
|
|
40
|
+
"长江证券": "Changjiang Securities", "方正证券": "Founder Securities", "东方证券": "Orient Securities", "平安证券": "Ping An Securities",
|
|
41
|
+
"安信证券": "Essence Securities", "国金证券": "Sinolink Securities", "华西证券": "Huaxi Securities", "浙商证券": "Zheshang Securities",
|
|
42
|
+
"财通证券": "Caitong Securities", "长城证券": "Great Wall Securities", "中泰证券": "Zhongtai Securities", "东吴证券": "Soochow Securities",
|
|
43
|
+
"华安证券": "Huaan Securities", "国元证券": "Guoyuan Securities", "西部证券": "Western Securities", "天风证券": "Tianfeng Securities",
|
|
44
|
+
"开源证券": "Kaiyuan Securities", "华鑫证券": "Huaxin Securities", "中金财富": "CICC Wealth", "瑞银证券": "UBS Securities",
|
|
45
|
+
"摩根大通证券": "J.P. Morgan Securities", "高盛高华": "Goldman Sachs Gao Hua", "华宝证券": "Huabao Securities",
|
|
46
|
+
"股份有限公司": "", "有限责任公司": "", "有限公司": "", "分公司": " branch", "证券营业部": " branch", "营业部": " branch",
|
|
47
|
+
"证券": " Securities", "总部": " HQ", "路": " Rd", "大道": " Ave", "大街": " St", "街": " St",
|
|
48
|
+
"新区": " New Area", "开发区": " Dev Zone", "自贸区": " FTZ",
|
|
49
|
+
}
|
|
50
|
+
_GLOSS = sorted(GLOSSARY.items(), key=lambda kv: -len(kv[0]))
|
|
51
|
+
_SUFFIX = re.compile(r",?\s*(co\.?,?\s*ltd\.?|company limited|co\.?,?\s*limited|limited|corporation|corp\.?|inc\.?)\s*$", re.I)
|
|
52
|
+
|
|
53
|
+
_lock = threading.Lock()
|
|
54
|
+
_names: Optional[dict[str, str]] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def has_cjk(text: Optional[str]) -> bool:
|
|
58
|
+
return bool(text and CJK.search(text))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pinyin(text: str) -> str:
|
|
62
|
+
"""CJK runs -> capitalised pinyin words; everything else kept."""
|
|
63
|
+
try:
|
|
64
|
+
from pypinyin import lazy_pinyin
|
|
65
|
+
except ImportError:
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
def run(m: re.Match) -> str:
|
|
69
|
+
# No word segmentation offline; most Chinese place and street names
|
|
70
|
+
# are two characters, so pair the syllables: "Ningbo Tiantong Bei".
|
|
71
|
+
syl = lazy_pinyin(m.group(0))
|
|
72
|
+
return " " + " ".join("".join(syl[i:i + 2]).capitalize() for i in range(0, len(syl), 2)) + " "
|
|
73
|
+
|
|
74
|
+
return re.sub(r"[㐀-鿿]+", run, text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def english_text(text: str) -> str:
|
|
78
|
+
if not has_cjk(text):
|
|
79
|
+
return text
|
|
80
|
+
out = text
|
|
81
|
+
for zh, en in _GLOSS:
|
|
82
|
+
out = out.replace(zh, f" {en} " if en and not en.startswith(" ") else en)
|
|
83
|
+
out = pinyin(out)
|
|
84
|
+
out = re.sub(r"\s+", " ", out).strip()
|
|
85
|
+
return re.sub(r"\s+([,)])", r"\1", out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clean_company(name: str) -> str:
|
|
89
|
+
return _SUFFIX.sub("", (name or "").strip()).strip(" ,")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------ stock names
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _path() -> Path:
|
|
96
|
+
path = app_home() / "cache"
|
|
97
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
return path / "english_names.json"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load() -> dict[str, str]:
|
|
102
|
+
global _names
|
|
103
|
+
if _names is None:
|
|
104
|
+
try:
|
|
105
|
+
_names = json.loads(_path().read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
_names = {}
|
|
108
|
+
return _names
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _save() -> None:
|
|
112
|
+
try:
|
|
113
|
+
_path().write_text(json.dumps(_names, ensure_ascii=False, indent=0), encoding="utf-8")
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
logger.warning("English names cache not saved: %s", exc)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _yahoo_name(yahoo: str) -> Optional[str]:
|
|
119
|
+
import yfinance as yf
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
info = yf.Ticker(yahoo).info or {}
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
name = info.get("longName") or info.get("shortName")
|
|
126
|
+
return clean_company(name) if name and not has_cjk(name) else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def english_names(items: list[tuple[str, str]], market: str,
|
|
130
|
+
lookup: Callable[[str], Optional[str]] = _yahoo_name) -> dict[str, str]:
|
|
131
|
+
"""[(symbol, chinese name)] -> {symbol: English name}. Cached forever;
|
|
132
|
+
unknown to Yahoo -> pinyin of the Chinese name (also cached)."""
|
|
133
|
+
from .analysis_run import yahoo_symbol
|
|
134
|
+
|
|
135
|
+
with _lock:
|
|
136
|
+
names = _load()
|
|
137
|
+
missing = [(s, n) for s, n in items if f"{market}:{s}" not in names][:MAX_LOOKUPS]
|
|
138
|
+
if missing:
|
|
139
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
140
|
+
found = list(pool.map(lambda sn: lookup(yahoo_symbol(sn[0], market)), missing))
|
|
141
|
+
with _lock:
|
|
142
|
+
for (sym, zh), en in zip(missing, found):
|
|
143
|
+
names[f"{market}:{sym}"] = en or english_text(zh)
|
|
144
|
+
_save()
|
|
145
|
+
with _lock:
|
|
146
|
+
return {s: names[f"{market}:{s}"] for s, _ in items if f"{market}:{s}" in names}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Valuation meter for one stock: how its price compares with its sector.
|
|
2
|
+
|
|
3
|
+
At today's price, the stock's P/E, price/book and price/sales are each divided
|
|
4
|
+
by the median of the same sector among the market's largest companies (the
|
|
5
|
+
universe the ready-made screens use). The combined position is the geometric
|
|
6
|
+
mean of those multiples:
|
|
7
|
+
|
|
8
|
+
below 0.8x -> priced below its sector
|
|
9
|
+
0.8 - 1.2x -> in line with its sector
|
|
10
|
+
above 1.2x -> priced above its sector
|
|
11
|
+
|
|
12
|
+
A separate growth check reports PEG (P/E / earnings growth %) and the rules of
|
|
13
|
+
the "Growth at a reasonable price" screen, each pass/fail, so a dear stock
|
|
14
|
+
that is growing fast is visible as such. Everything is a comparison with
|
|
15
|
+
numbers shown; the label wording is chosen by the frontend (see
|
|
16
|
+
sebi-compliance-mode.md: no bare "overvalued" on public deploys).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
|
|
27
|
+
from ..core.numbers import finite as _num
|
|
28
|
+
from ..core.numbers import median as _median
|
|
29
|
+
from ..data.yahoo_price import info_price
|
|
30
|
+
|
|
31
|
+
UNIVERSE = 200
|
|
32
|
+
MIN_PEERS = 5 # profitable companies a sector needs before its median means anything
|
|
33
|
+
BELOW, ABOVE = 0.8, 1.2
|
|
34
|
+
CACHE_SECONDS = 6 * 3600
|
|
35
|
+
|
|
36
|
+
# key -> (Yahoo field, label)
|
|
37
|
+
MULTIPLES = {
|
|
38
|
+
"pe": ("trailingPE", "P/E"),
|
|
39
|
+
"pb": ("priceToBook", "Price / book"),
|
|
40
|
+
"ps": ("priceToSalesTrailing12Months", "Price / sales"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
InfoFetch = Callable[[str], dict]
|
|
44
|
+
|
|
45
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
46
|
+
_lock = threading.Lock()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sector_medians(market: str, info_fetch: Optional[InfoFetch] = None, rows: Optional[list[dict]] = None) -> dict:
|
|
50
|
+
"""sector -> {pe, pb, ps: median or None, n: profitable companies}, from
|
|
51
|
+
the market's largest stocks. Cached for a few hours per market."""
|
|
52
|
+
if info_fetch is None and rows is None:
|
|
53
|
+
with _lock:
|
|
54
|
+
hit = _cache.get(market)
|
|
55
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
56
|
+
return hit[1]
|
|
57
|
+
from .analysis_run import yahoo_symbol
|
|
58
|
+
from .compare import _yahoo_info
|
|
59
|
+
|
|
60
|
+
fetch = info_fetch or _yahoo_info
|
|
61
|
+
if rows is None:
|
|
62
|
+
from ..data.listings import get_store
|
|
63
|
+
|
|
64
|
+
rows = get_store().rows(market)
|
|
65
|
+
stocks = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
|
|
66
|
+
key=lambda r: -r["market_cap"])[:UNIVERSE]
|
|
67
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
68
|
+
infos = list(pool.map(lambda r: fetch(yahoo_symbol(r["symbol"], market)), stocks))
|
|
69
|
+
|
|
70
|
+
groups: dict[str, dict[str, list[float]]] = {}
|
|
71
|
+
for info in infos:
|
|
72
|
+
sector = (info or {}).get("sector")
|
|
73
|
+
pe = _num((info or {}).get("trailingPE"))
|
|
74
|
+
if not sector or not pe or pe <= 0:
|
|
75
|
+
continue # loss-makers would drag a P/E median around
|
|
76
|
+
g = groups.setdefault(sector, {k: [] for k in MULTIPLES})
|
|
77
|
+
for key, (field, _) in MULTIPLES.items():
|
|
78
|
+
v = _num(info.get(field))
|
|
79
|
+
if v and v > 0:
|
|
80
|
+
g[key].append(v)
|
|
81
|
+
out = {s: {"n": len(g["pe"]), **{k: _median(g[k]) for k in MULTIPLES}} for s, g in groups.items()}
|
|
82
|
+
if info_fetch is None and rows is None:
|
|
83
|
+
with _lock:
|
|
84
|
+
_cache[market] = (time.time(), out)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def zone(position: Optional[float]) -> Optional[str]:
|
|
89
|
+
if position is None:
|
|
90
|
+
return None
|
|
91
|
+
if position < BELOW:
|
|
92
|
+
return "below"
|
|
93
|
+
if position > ABOVE:
|
|
94
|
+
return "above"
|
|
95
|
+
return "in_line"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _garp(info: dict, pe: Optional[float]) -> dict:
|
|
99
|
+
"""PEG and the "Growth at a reasonable price" screen rules, each checked."""
|
|
100
|
+
from .screens import PRESETS, _rule_text, metrics
|
|
101
|
+
|
|
102
|
+
m = metrics(info)
|
|
103
|
+
growth = m.get("earnings_growth")
|
|
104
|
+
peg = pe / growth if pe and pe > 0 and growth and growth > 0 else None
|
|
105
|
+
rules = []
|
|
106
|
+
for rule in PRESETS["garp"][3]:
|
|
107
|
+
field, op, value = rule
|
|
108
|
+
v = m.get(field)
|
|
109
|
+
ok = v is not None and ((op == ">" and v > value) or (op == "<" and v < value)
|
|
110
|
+
or (op == "between" and value[0] < v < value[1]))
|
|
111
|
+
# pass: True / False, or None when Yahoo has no figure (not counted).
|
|
112
|
+
rules.append({"rule": _rule_text(rule), "field": field, "value": v, "pass": None if v is None else bool(ok)})
|
|
113
|
+
known = [r for r in rules if r["pass"] is not None]
|
|
114
|
+
return {
|
|
115
|
+
"peg": peg,
|
|
116
|
+
"earnings_growth": growth,
|
|
117
|
+
"revenue_growth": m.get("revenue_growth"),
|
|
118
|
+
"rules": rules,
|
|
119
|
+
"passed": sum(1 for r in known if r["pass"]),
|
|
120
|
+
"total": len(known),
|
|
121
|
+
"missing": len(rules) - len(known),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def meter(symbol: str, market: str, info_fetch: Optional[InfoFetch] = None,
|
|
126
|
+
medians: Optional[dict] = None) -> dict:
|
|
127
|
+
from .analysis_run import yahoo_symbol
|
|
128
|
+
from .compare import _yahoo_info
|
|
129
|
+
|
|
130
|
+
if market == "crypto":
|
|
131
|
+
return {"status": "unavailable", "reason": "Crypto has no earnings or book value to compare."}
|
|
132
|
+
info = (info_fetch or _yahoo_info)(yahoo_symbol(symbol, market)) or {}
|
|
133
|
+
if not info:
|
|
134
|
+
return {"status": "unavailable", "reason": "No fundamentals available for this symbol right now."}
|
|
135
|
+
sector = info.get("sector")
|
|
136
|
+
if medians is None:
|
|
137
|
+
medians = sector_medians(market)
|
|
138
|
+
peers = medians.get(sector or "") or {}
|
|
139
|
+
|
|
140
|
+
pe = _num(info.get("trailingPE"))
|
|
141
|
+
multiples, ratios = [], []
|
|
142
|
+
for key, (field, label) in MULTIPLES.items():
|
|
143
|
+
v = _num(info.get(field))
|
|
144
|
+
med = peers.get(key) if peers.get("n", 0) >= MIN_PEERS else None
|
|
145
|
+
ratio = v / med if v and v > 0 and med else None
|
|
146
|
+
if ratio:
|
|
147
|
+
ratios.append(ratio)
|
|
148
|
+
multiples.append({"key": key, "label": label, "value": v, "sector_median": med, "ratio": ratio})
|
|
149
|
+
|
|
150
|
+
position = math.exp(sum(math.log(r) for r in ratios) / len(ratios)) if ratios else None
|
|
151
|
+
reason = None
|
|
152
|
+
if position is None:
|
|
153
|
+
if not sector:
|
|
154
|
+
reason = "Our market data has no sector for this company, so there is no peer group to compare with."
|
|
155
|
+
elif peers.get("n", 0) < MIN_PEERS:
|
|
156
|
+
reason = f"Too few profitable {sector} companies among the market's largest to form a sector median."
|
|
157
|
+
else:
|
|
158
|
+
reason = "The company has no positive P/E, book value or sales figure to compare (for example a loss-maker)."
|
|
159
|
+
return {
|
|
160
|
+
"status": "ok" if position is not None else "unavailable",
|
|
161
|
+
"reason": reason,
|
|
162
|
+
"symbol": symbol,
|
|
163
|
+
"sector": sector,
|
|
164
|
+
"peers": peers.get("n", 0),
|
|
165
|
+
"universe": f"the {UNIVERSE} largest companies in this market",
|
|
166
|
+
"price": info_price(info),
|
|
167
|
+
"currency": info.get("currency"),
|
|
168
|
+
"multiples": multiples,
|
|
169
|
+
"position": position,
|
|
170
|
+
"zone": zone(position),
|
|
171
|
+
"bands": {"below": BELOW, "above": ABOVE},
|
|
172
|
+
"garp": _garp(info, pe),
|
|
173
|
+
}
|