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,335 @@
|
|
|
1
|
+
"""Big trades outside India (India: NSE bulk/block deals, marketmyna/data/nse_deals.py).
|
|
2
|
+
|
|
3
|
+
- us: insider open-market buys and sells from the latest SEC Form 4
|
|
4
|
+
filings (EDGAR). Needs SEC_USER_AGENT (name + contact email),
|
|
5
|
+
as SEC's fair-access policy requires.
|
|
6
|
+
- china_a: Shanghai / Shenzhen block trades (大宗交易) with buyer and seller
|
|
7
|
+
branches, via Eastmoney's data centre (same host the listings use).
|
|
8
|
+
- hong_kong: Southbound Stock Connect - the HK stocks mainland investors
|
|
9
|
+
bought and sold most each day (Shanghai and Shenzhen links).
|
|
10
|
+
- crypto: no public trade-level whale feed, so the closest free signal:
|
|
11
|
+
Binance futures positioning of the largest accounts (top-trader
|
|
12
|
+
long/short), open interest change and taker buy/sell balance.
|
|
13
|
+
|
|
14
|
+
Each market is cached for CACHE_SECONDS.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import xml.etree.ElementTree as ET
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from datetime import date, datetime, timedelta
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
CACHE_SECONDS = 3600
|
|
31
|
+
EASTMONEY = "https://datacenter-web.eastmoney.com/api/data/v1/get"
|
|
32
|
+
SEC_FEED = ("https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=4&company=&dateb=&owner=include"
|
|
33
|
+
"&start={start}&count=100&output=atom")
|
|
34
|
+
POSITION_COINS = ["BTC", "ETH", "SOL", "XRP", "BNB", "DOGE", "ADA", "AVAX", "LINK", "SUI"]
|
|
35
|
+
|
|
36
|
+
_cache: dict[tuple, tuple[float, dict]] = {}
|
|
37
|
+
_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _json(url: str, params: Optional[dict] = None, headers: Optional[dict] = None):
|
|
41
|
+
import requests
|
|
42
|
+
|
|
43
|
+
r = requests.get(url, params=params, timeout=20, headers=headers or {"User-Agent": "Mozilla/5.0 MarketMyna"})
|
|
44
|
+
r.raise_for_status()
|
|
45
|
+
return r.json()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _num(v) -> Optional[float]:
|
|
49
|
+
try:
|
|
50
|
+
return float(v)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------- US: Form 4
|
|
56
|
+
|
|
57
|
+
_ATOM = {"a": "http://www.w3.org/2005/Atom"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_feed(xml_text: str) -> list[tuple[str, str]]:
|
|
61
|
+
"""Atom feed -> unique (cik, accession) of Form 4 filings, issuer entries first."""
|
|
62
|
+
out: dict[str, str] = {}
|
|
63
|
+
for entry in ET.fromstring(xml_text).findall("a:entry", _ATOM):
|
|
64
|
+
link = entry.find("a:link", _ATOM)
|
|
65
|
+
href = link.get("href") if link is not None else ""
|
|
66
|
+
m = re.search(r"/data/(\d+)/(\d{18})/", href or "")
|
|
67
|
+
if m and m.group(2) not in out:
|
|
68
|
+
out[m.group(2)] = m.group(1)
|
|
69
|
+
return [(cik, acc) for acc, cik in out.items()]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _t(node, path: str) -> str:
|
|
73
|
+
el = node.find(path)
|
|
74
|
+
return (el.text or "").strip() if el is not None and el.text else ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_form4(xml_text: str) -> list[dict]:
|
|
78
|
+
"""Open-market purchases (P) and sales (S) in one Form 4, one row per
|
|
79
|
+
side, shares summed and price volume-weighted."""
|
|
80
|
+
root = ET.fromstring(xml_text)
|
|
81
|
+
symbol = _t(root, "issuer/issuerTradingSymbol").upper()
|
|
82
|
+
issuer = _t(root, "issuer/issuerName")
|
|
83
|
+
owner = _t(root, "reportingOwner/reportingOwnerId/rptOwnerName")
|
|
84
|
+
rel = root.find("reportingOwner/reportingOwnerRelationship")
|
|
85
|
+
roles = []
|
|
86
|
+
if rel is not None:
|
|
87
|
+
if _t(rel, "isDirector") in ("1", "true"):
|
|
88
|
+
roles.append("Director")
|
|
89
|
+
if _t(rel, "isOfficer") in ("1", "true"):
|
|
90
|
+
roles.append(_t(rel, "officerTitle") or "Officer")
|
|
91
|
+
if _t(rel, "isTenPercentOwner") in ("1", "true"):
|
|
92
|
+
roles.append("10% owner")
|
|
93
|
+
sides: dict[str, dict] = {}
|
|
94
|
+
for tx in root.findall("nonDerivativeTable/nonDerivativeTransaction"):
|
|
95
|
+
code = _t(tx, "transactionCoding/transactionCode")
|
|
96
|
+
if code not in ("P", "S"):
|
|
97
|
+
continue # grants, option exercises, tax withholding... are not market trades
|
|
98
|
+
shares = _num(_t(tx, "transactionAmounts/transactionShares/value")) or 0
|
|
99
|
+
price = _num(_t(tx, "transactionAmounts/transactionPricePerShare/value")) or 0
|
|
100
|
+
side = sides.setdefault(code, {"qty": 0.0, "value": 0.0, "date": _t(tx, "transactionDate/value")})
|
|
101
|
+
side["qty"] += shares
|
|
102
|
+
side["value"] += shares * price
|
|
103
|
+
rows = []
|
|
104
|
+
for code, s in sides.items():
|
|
105
|
+
if not s["qty"]:
|
|
106
|
+
continue
|
|
107
|
+
rows.append({
|
|
108
|
+
"date": s["date"][:10], "kind": "insider", "symbol": symbol, "name": issuer,
|
|
109
|
+
"client": owner.title() if owner.isupper() else owner, "side": "buy" if code == "P" else "sell",
|
|
110
|
+
"qty": s["qty"], "price": s["value"] / s["qty"] if s["qty"] else None, "value": s["value"] or None,
|
|
111
|
+
"remarks": ", ".join(roles) or None,
|
|
112
|
+
})
|
|
113
|
+
return rows
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _us(fetch_text: Optional[Callable[[str], str]] = None, max_filings: int = 120) -> dict:
|
|
117
|
+
from .sec_mdna import user_agent
|
|
118
|
+
|
|
119
|
+
ua = user_agent() # raises SecNotConfigured with the fix
|
|
120
|
+
if fetch_text is None:
|
|
121
|
+
import requests
|
|
122
|
+
|
|
123
|
+
session = requests.Session()
|
|
124
|
+
session.headers.update({"User-Agent": ua, "Accept-Encoding": "gzip, deflate"})
|
|
125
|
+
gate = threading.Semaphore(6) # SEC allows 10 requests/second
|
|
126
|
+
|
|
127
|
+
def fetch_text(url: str) -> str:
|
|
128
|
+
with gate:
|
|
129
|
+
r = session.get(url, timeout=20)
|
|
130
|
+
time.sleep(0.15)
|
|
131
|
+
r.raise_for_status()
|
|
132
|
+
return r.text
|
|
133
|
+
|
|
134
|
+
filings: list[tuple[str, str]] = []
|
|
135
|
+
for start in (0, 100):
|
|
136
|
+
filings += parse_feed(fetch_text(SEC_FEED.format(start=start)))
|
|
137
|
+
seen, unique = set(), []
|
|
138
|
+
for cik, acc in filings:
|
|
139
|
+
if acc not in seen:
|
|
140
|
+
seen.add(acc)
|
|
141
|
+
unique.append((cik, acc))
|
|
142
|
+
unique = unique[:max_filings]
|
|
143
|
+
|
|
144
|
+
def one(item):
|
|
145
|
+
cik, acc = item
|
|
146
|
+
try:
|
|
147
|
+
import json
|
|
148
|
+
|
|
149
|
+
index = json.loads(fetch_text(f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/index.json"))
|
|
150
|
+
names = [f["name"] for f in index.get("directory", {}).get("item", []) if f["name"].endswith(".xml")]
|
|
151
|
+
if not names:
|
|
152
|
+
return []
|
|
153
|
+
return parse_form4(fetch_text(f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/{names[0]}"))
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
logger.debug("Form 4 %s skipped: %s", acc, exc)
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
with ThreadPoolExecutor(max_workers=6) as pool:
|
|
159
|
+
rows = [r for found in pool.map(one, unique) for r in found]
|
|
160
|
+
rows.sort(key=lambda r: -(r["value"] or 0))
|
|
161
|
+
return {
|
|
162
|
+
"title": "Insider buys & sells (SEC Form 4)",
|
|
163
|
+
"subtitle": "Open-market purchases and sales by executives, directors and 10% owners in the latest filings",
|
|
164
|
+
"currency": "$", "deals": rows,
|
|
165
|
+
"note": f"Latest {len(unique)} insider filings (Form 4) with the US regulator; open-market trades only (code P / S), "
|
|
166
|
+
"so grants, option exercises and tax withholding are left out. Insiders sell for many reasons; "
|
|
167
|
+
"buys are the rarer, stronger signal.",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# -------------------------------------------------- China A: block trades
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_blocks(rows: list[dict]) -> list[dict]:
|
|
175
|
+
out = []
|
|
176
|
+
for r in rows:
|
|
177
|
+
if str(r.get("SECURITY_TYPE_WEB")) != "1": # 1 = A-share stock (skip ETFs, bonds)
|
|
178
|
+
continue
|
|
179
|
+
premium = _num(r.get("PREMIUM_RATIO"))
|
|
180
|
+
out.append({
|
|
181
|
+
"date": str(r.get("TRADE_DATE", ""))[:10], "kind": "block", "symbol": r.get("SECURITY_CODE"),
|
|
182
|
+
"name": r.get("SECURITY_NAME_ABBR"),
|
|
183
|
+
"client": f"{r.get('BUYER_NAME') or '?'} ← {r.get('SELLER_NAME') or '?'}",
|
|
184
|
+
"side": "premium" if (premium or 0) > 0 else "discount" if (premium or 0) < 0 else "at market",
|
|
185
|
+
"qty": _num(r.get("DEAL_VOLUME")), "price": _num(r.get("DEAL_PRICE")), "value": _num(r.get("DEAL_AMT")),
|
|
186
|
+
"remarks": f"{premium * 100:+.1f}% vs close" if premium is not None else None,
|
|
187
|
+
})
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _china(days: int, fetch=_json) -> dict:
|
|
192
|
+
since = (date.today() - timedelta(days=days + 16)).isoformat() # holidays (Golden Week, CNY) can run 9 days
|
|
193
|
+
data = fetch(EASTMONEY, {
|
|
194
|
+
"reportName": "RPT_DATA_BLOCKTRADE", "columns": "ALL", "sortColumns": "TRADE_DATE,DEAL_AMT", "sortTypes": "-1,-1",
|
|
195
|
+
"pageSize": 500, "pageNumber": 1, "source": "WEB", "client": "WEB", "filter": f"(TRADE_DATE>='{since}')",
|
|
196
|
+
})
|
|
197
|
+
rows = parse_blocks(((data or {}).get("result") or {}).get("data") or [])
|
|
198
|
+
if rows:
|
|
199
|
+
latest = max(r["date"] for r in rows)
|
|
200
|
+
cutoff = (date.fromisoformat(latest) - timedelta(days=days - 1)).isoformat()
|
|
201
|
+
rows = [r for r in rows if r["date"] >= cutoff]
|
|
202
|
+
rows.sort(key=lambda r: -(r["value"] or 0))
|
|
203
|
+
return {
|
|
204
|
+
"title": "Block trades (Shanghai & Shenzhen)",
|
|
205
|
+
"subtitle": "Large negotiated trades between brokerage branches, with the price against the day's close",
|
|
206
|
+
"currency": "¥", "deals": rows,
|
|
207
|
+
"note": "Buyer ← seller are brokerage branches; 机构专用 means an institution's dedicated seat. A deep discount "
|
|
208
|
+
"often means a large holder exiting; a premium, a buyer keen to get size. Source: Eastmoney.",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------ Hong Kong: southbound connect
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_southbound(rows: list[dict], link: str) -> list[dict]:
|
|
216
|
+
out = []
|
|
217
|
+
for r in rows:
|
|
218
|
+
net = _num(r.get("NET_BUY_AMT"))
|
|
219
|
+
out.append({
|
|
220
|
+
"date": str(r.get("TRADE_DATE", ""))[:10], "kind": f"southbound ({link})", "symbol": r.get("SECURITY_CODE"),
|
|
221
|
+
"name": r.get("SECURITY_NAME"), "client": f"Mainland investors via {link}",
|
|
222
|
+
"side": "net buy" if (net or 0) >= 0 else "net sell", "qty": None, "price": _num(r.get("CLOSE_PRICE")),
|
|
223
|
+
"value": abs(net) if net is not None else _num(r.get("DEAL_AMT")),
|
|
224
|
+
"remarks": (f"bought HK${(_num(r.get('BUY_AMT')) or 0) / 1e6:,.0f}m · sold HK${(_num(r.get('SELL_AMT')) or 0) / 1e6:,.0f}m"
|
|
225
|
+
if r.get("BUY_AMT") is not None else None),
|
|
226
|
+
})
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _hk_english(rows: list[dict], names: Optional[dict[str, str]] = None) -> None:
|
|
231
|
+
"""Eastmoney sends Chinese names; our HK listing has the English ones."""
|
|
232
|
+
if names is None:
|
|
233
|
+
try:
|
|
234
|
+
from ..data.listings import get_store
|
|
235
|
+
|
|
236
|
+
names = {r["symbol"]: r["name"] for r in get_store().rows("hong_kong")}
|
|
237
|
+
except Exception:
|
|
238
|
+
names = {}
|
|
239
|
+
for r in rows:
|
|
240
|
+
en = names.get(str(r.get("symbol")))
|
|
241
|
+
if en:
|
|
242
|
+
r["name_local"], r["name"] = r["name"], en
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _hong_kong(days: int, fetch=_json, names: Optional[dict[str, str]] = None) -> dict:
|
|
246
|
+
since = (date.today() - timedelta(days=days + 16)).isoformat() # holidays (Golden Week, CNY) can run 9 days
|
|
247
|
+
rows: list[dict] = []
|
|
248
|
+
for code, link in (("002", "Shanghai link"), ("004", "Shenzhen link")):
|
|
249
|
+
data = fetch(EASTMONEY, {
|
|
250
|
+
"reportName": "RPT_MUTUAL_TOP10DEAL", "columns": "ALL", "sortColumns": "TRADE_DATE,RANK", "sortTypes": "-1,1",
|
|
251
|
+
"pageSize": 200, "pageNumber": 1, "source": "WEB", "client": "WEB",
|
|
252
|
+
"filter": f'(MUTUAL_TYPE="{code}")(TRADE_DATE>=\'{since}\')',
|
|
253
|
+
})
|
|
254
|
+
rows += parse_southbound(((data or {}).get("result") or {}).get("data") or [], link)
|
|
255
|
+
if rows:
|
|
256
|
+
latest = max(r["date"] for r in rows)
|
|
257
|
+
cutoff = (date.fromisoformat(latest) - timedelta(days=days - 1)).isoformat()
|
|
258
|
+
rows = [r for r in rows if r["date"] >= cutoff]
|
|
259
|
+
rows.sort(key=lambda r: -(r["value"] or 0))
|
|
260
|
+
_hk_english(rows, names)
|
|
261
|
+
return {
|
|
262
|
+
"title": "Southbound Stock Connect",
|
|
263
|
+
"subtitle": "The Hong Kong stocks mainland investors traded most, and whether they were net buyers or sellers",
|
|
264
|
+
"currency": "HK$", "deals": rows,
|
|
265
|
+
"note": "Daily top-10 traded stocks on each southbound link (Shanghai and Shenzhen), sorted by net amount. "
|
|
266
|
+
"Mainland flows have become a big driver of HK prices. Source: Eastmoney / HKEX.",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------ crypto: big-trader positioning
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _coin_positioning(coin: str, fetch=_json) -> Optional[dict]:
|
|
274
|
+
base = "https://fapi.binance.com/futures/data"
|
|
275
|
+
sym = f"{coin}USDT"
|
|
276
|
+
top = fetch(f"{base}/topLongShortPositionRatio", {"symbol": sym, "period": "1d", "limit": 8})
|
|
277
|
+
oi = fetch(f"{base}/openInterestHist", {"symbol": sym, "period": "1d", "limit": 8})
|
|
278
|
+
taker = fetch(f"{base}/takerlongshortRatio", {"symbol": sym, "period": "1d", "limit": 2})
|
|
279
|
+
if not top or not oi:
|
|
280
|
+
return None
|
|
281
|
+
last_top, prev_top = top[-1], top[-2] if len(top) > 1 else top[-1]
|
|
282
|
+
oi_now = _num(oi[-1]["sumOpenInterestValue"])
|
|
283
|
+
oi_1d = _num(oi[-2]["sumOpenInterestValue"]) if len(oi) > 1 else None
|
|
284
|
+
oi_7d = _num(oi[0]["sumOpenInterestValue"]) if len(oi) > 1 else None
|
|
285
|
+
return {
|
|
286
|
+
"coin": coin,
|
|
287
|
+
"top_long_pct": round(_num(last_top["longAccount"]) * 100, 1),
|
|
288
|
+
"top_ratio": _num(last_top["longShortRatio"]),
|
|
289
|
+
"top_ratio_change": (_num(last_top["longShortRatio"]) or 0) - (_num(prev_top["longShortRatio"]) or 0),
|
|
290
|
+
"open_interest": oi_now,
|
|
291
|
+
"oi_change_1d": (oi_now / oi_1d - 1) * 100 if oi_now and oi_1d else None,
|
|
292
|
+
"oi_change_7d": (oi_now / oi_7d - 1) * 100 if oi_now and oi_7d else None,
|
|
293
|
+
"taker_ratio": _num(taker[-1]["buySellRatio"]) if taker else None,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _crypto(fetch=_json) -> dict:
|
|
298
|
+
with ThreadPoolExecutor(max_workers=5) as pool:
|
|
299
|
+
rows = [r for r in pool.map(lambda c: _safe(_coin_positioning, c, fetch), POSITION_COINS) if r]
|
|
300
|
+
return {
|
|
301
|
+
"title": "Whale positioning (crypto futures)",
|
|
302
|
+
"subtitle": "How the largest futures accounts are positioned, and whether leverage is building or unwinding",
|
|
303
|
+
"currency": "$", "positions": rows,
|
|
304
|
+
"note": "Top traders = the 20% of accounts with the biggest margin balances; their long % is by position size. "
|
|
305
|
+
"Open interest = total value of open futures; rising OI with rising price = new money, falling OI = "
|
|
306
|
+
"positions closing. Taker ratio > 1 = more aggressive buying than selling yesterday. Source: a major crypto exchange's futures data.",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _safe(fn, *args):
|
|
311
|
+
try:
|
|
312
|
+
return fn(*args)
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
logger.info("Positioning %s skipped: %s", args[0], exc)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def build(market: str, days: int = 1, use_cache: bool = True) -> dict:
|
|
319
|
+
market = market.lower()
|
|
320
|
+
builders: dict[str, Callable[[], dict]] = {
|
|
321
|
+
"us": _us, "china_a": lambda: _china(days), "hong_kong": lambda: _hong_kong(days), "crypto": _crypto,
|
|
322
|
+
}
|
|
323
|
+
if market not in builders:
|
|
324
|
+
raise ValueError("Big trades are available for India, US, China A, Hong Kong and crypto")
|
|
325
|
+
key = (market, days if market in ("china_a", "hong_kong") else 0)
|
|
326
|
+
if use_cache:
|
|
327
|
+
with _lock:
|
|
328
|
+
hit = _cache.get(key)
|
|
329
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
330
|
+
return hit[1]
|
|
331
|
+
out = {"market": market, "days": days, "as_of": datetime.now().isoformat(timespec="minutes"), **builders[market]()}
|
|
332
|
+
if use_cache and (out.get("deals") or out.get("positions")):
|
|
333
|
+
with _lock:
|
|
334
|
+
_cache[key] = (time.time(), out)
|
|
335
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Angel One SmartAPI: live NSE prices for the Markets / Watchlist tables.
|
|
2
|
+
|
|
3
|
+
Free with an Angel One account (smartapi.angelone.in -> create an app for
|
|
4
|
+
the API key). The server logs itself in every day with the client code,
|
|
5
|
+
PIN and the TOTP secret shown when enabling TOTP, so prices keep flowing
|
|
6
|
+
without a manual morning login.
|
|
7
|
+
|
|
8
|
+
Only the Market Quote REST endpoint is used (up to 50 symbols per call).
|
|
9
|
+
NSE symbols are mapped to Angel's instrument tokens with their public
|
|
10
|
+
instrument master, cached on disk for a day.
|
|
11
|
+
|
|
12
|
+
Note: showing exchange prices to the public needs the exchange's data
|
|
13
|
+
licence; check NSE's terms before launch.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import struct
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from marketmyna.core.home import app_home
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
API = os.environ.get("ANGEL_API_ROOT", "https://apiconnect.angelone.in")
|
|
35
|
+
SCRIP_MASTER = os.environ.get(
|
|
36
|
+
"ANGEL_SCRIP_MASTER", "https://margincalculator.angelone.in/OpenAPI_File/files/OpenAPIScripMaster.json")
|
|
37
|
+
BATCH = 50 # symbols per quote call (API limit)
|
|
38
|
+
# Market Quote limits per Angel account (docs: RateLimit): 10/s, 500/min,
|
|
39
|
+
# 5000/hour. Every app user shares the one account, so stay under the
|
|
40
|
+
# hourly cap: about 1.1 calls a second for the tables (~4,000 an hour), which
|
|
41
|
+
# leaves room for the Market insights' live prices (services/live_market.py:
|
|
42
|
+
# 10 calls a minute for the 500 largest names, ~600 an hour).
|
|
43
|
+
CALLS_PER_SECOND = float(os.environ.get("ANGEL_QUOTE_CALLS_PER_SECOND", 1.1))
|
|
44
|
+
MASTER_MAX_AGE = 20 * 3600
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AngelError(Exception):
|
|
48
|
+
def __init__(self, message: str, code: str = "", http: int = 0):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code, self.http = code, http
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def session_expired(self) -> bool:
|
|
54
|
+
# AG8001 invalid / AG8002 expired / AG8003 missing token (docs: Error Codes).
|
|
55
|
+
return self.http == 401 or self.code in ("AG8001", "AG8002", "AG8003")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def totp(secret: str, now: Optional[float] = None, digits: int = 6, step: int = 30) -> str:
|
|
59
|
+
"""RFC 6238 time-based code from a base32 secret (what authenticator apps show)."""
|
|
60
|
+
key = base64.b32decode(secret.replace(" ", "").upper() + "=" * (-len(secret.replace(" ", "")) % 8))
|
|
61
|
+
counter = int((now if now is not None else time.time()) // step)
|
|
62
|
+
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
|
|
63
|
+
offset = digest[-1] & 0x0F
|
|
64
|
+
code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % (10 ** digits)
|
|
65
|
+
return str(code).zfill(digits)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configured(config) -> bool:
|
|
69
|
+
return all(getattr(config, f, None) for f in ("angel_api_key", "angel_client_code", "angel_pin", "angel_totp_secret"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _headers(config, token: Optional[str] = None) -> dict:
|
|
73
|
+
h = {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
"X-UserType": "USER",
|
|
77
|
+
"X-SourceID": "WEB",
|
|
78
|
+
"X-ClientLocalIP": "127.0.0.1",
|
|
79
|
+
"X-ClientPublicIP": "127.0.0.1",
|
|
80
|
+
"X-MACAddress": "00:00:00:00:00:00",
|
|
81
|
+
"X-PrivateKey": config.angel_api_key,
|
|
82
|
+
}
|
|
83
|
+
if token:
|
|
84
|
+
h["Authorization"] = f"Bearer {token}"
|
|
85
|
+
return h
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _post(path: str, config, body: dict, token: Optional[str] = None) -> dict:
|
|
89
|
+
import requests
|
|
90
|
+
|
|
91
|
+
r = requests.post(API + path, headers=_headers(config, token), data=json.dumps(body), timeout=10)
|
|
92
|
+
try:
|
|
93
|
+
data = r.json()
|
|
94
|
+
except ValueError:
|
|
95
|
+
detail = "rate limit exceeded" if r.status_code == 403 else "no JSON"
|
|
96
|
+
raise AngelError(f"Angel One answered HTTP {r.status_code} ({detail})", http=r.status_code)
|
|
97
|
+
# The docs use both status/errorcode and success/errorCode.
|
|
98
|
+
ok = data.get("status", data.get("success", False))
|
|
99
|
+
code = data.get("errorcode") or data.get("errorCode") or ""
|
|
100
|
+
if r.status_code >= 400 or not ok:
|
|
101
|
+
raise AngelError(data.get("message") or f"Angel One error (HTTP {r.status_code})", code=code, http=r.status_code)
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AngelClient:
|
|
106
|
+
"""One logged-in session, shared; logs in again when it expires."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, config):
|
|
109
|
+
self.config = config
|
|
110
|
+
self._token: Optional[str] = None
|
|
111
|
+
self._logged_at = 0.0
|
|
112
|
+
self._lock = threading.Lock()
|
|
113
|
+
self._tokens: dict[str, str] = {} # NSE symbol -> instrument token
|
|
114
|
+
self._tokens_at = 0.0
|
|
115
|
+
|
|
116
|
+
# -------------------------------------------------------------- session
|
|
117
|
+
def login(self) -> str:
|
|
118
|
+
cfg = self.config
|
|
119
|
+
data = _post("/rest/auth/angelbroking/user/v1/loginByPassword", cfg, {
|
|
120
|
+
"clientcode": cfg.angel_client_code, "password": cfg.angel_pin, "totp": totp(cfg.angel_totp_secret),
|
|
121
|
+
})
|
|
122
|
+
token = (data.get("data") or {}).get("jwtToken")
|
|
123
|
+
if not token:
|
|
124
|
+
raise AngelError("Angel One login returned no session token")
|
|
125
|
+
self._token, self._logged_at = token, time.time()
|
|
126
|
+
return token
|
|
127
|
+
|
|
128
|
+
def _session(self) -> str:
|
|
129
|
+
with self._lock:
|
|
130
|
+
# A SmartAPI session lasts until 12 midnight (IST): log in again on a new day.
|
|
131
|
+
if not self._token or _ist_date(self._logged_at) != _ist_date(time.time()):
|
|
132
|
+
self.login()
|
|
133
|
+
return self._token
|
|
134
|
+
|
|
135
|
+
# -------------------------------------------------------------- symbols
|
|
136
|
+
def _symbol_tokens(self) -> dict[str, str]:
|
|
137
|
+
if self._tokens and time.time() - self._tokens_at < MASTER_MAX_AGE:
|
|
138
|
+
return self._tokens
|
|
139
|
+
path = Path(getattr(self.config, "app_dir", app_home())) / "cache" / "angel_nse_tokens.json"
|
|
140
|
+
data: Optional[dict] = None
|
|
141
|
+
if path.exists() and time.time() - path.stat().st_mtime < MASTER_MAX_AGE:
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
144
|
+
except ValueError:
|
|
145
|
+
data = None
|
|
146
|
+
if data is None:
|
|
147
|
+
import requests
|
|
148
|
+
|
|
149
|
+
r = requests.get(SCRIP_MASTER, timeout=60)
|
|
150
|
+
r.raise_for_status()
|
|
151
|
+
data = parse_master(r.json())
|
|
152
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
154
|
+
self._tokens, self._tokens_at = data, time.time()
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
# -------------------------------------------------------------- quotes
|
|
158
|
+
def quotes(self, symbols: list[str]) -> dict[str, dict]:
|
|
159
|
+
"""NSE symbol -> {price, change, change_percent, volume, time}."""
|
|
160
|
+
tokens = self._symbol_tokens()
|
|
161
|
+
wanted = {tokens[s.upper()]: s.upper() for s in symbols if s.upper() in tokens}
|
|
162
|
+
out: dict[str, dict] = {}
|
|
163
|
+
ids = list(wanted)
|
|
164
|
+
for i in range(0, len(ids), BATCH):
|
|
165
|
+
chunk = ids[i:i + BATCH]
|
|
166
|
+
body = {"mode": "FULL", "exchangeTokens": {"NSE": chunk}}
|
|
167
|
+
try:
|
|
168
|
+
data = _post("/rest/secure/angelbroking/market/v1/quote/", self.config, body, self._session())
|
|
169
|
+
except AngelError as exc:
|
|
170
|
+
if not exc.session_expired:
|
|
171
|
+
raise # e.g. rate limit: logging in again would only add calls
|
|
172
|
+
with self._lock:
|
|
173
|
+
self._token = None
|
|
174
|
+
data = _post("/rest/secure/angelbroking/market/v1/quote/", self.config, body, self._session())
|
|
175
|
+
for q in (data.get("data") or {}).get("fetched") or []:
|
|
176
|
+
sym = wanted.get(str(q.get("symbolToken")))
|
|
177
|
+
ltp = _num(q.get("ltp"))
|
|
178
|
+
if sym and ltp:
|
|
179
|
+
out[sym] = {
|
|
180
|
+
"price": ltp,
|
|
181
|
+
"change": _num(q.get("netChange")),
|
|
182
|
+
"change_percent": _num(q.get("percentChange")),
|
|
183
|
+
"volume": _num(q.get("tradeVolume")),
|
|
184
|
+
"time": _feed_time(q.get("exchFeedTime")) or time.time(),
|
|
185
|
+
**order_book(q),
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def order_book(q: dict) -> dict:
|
|
191
|
+
"""FULL-mode extras: pending buy / sell quantity, the best five bids and
|
|
192
|
+
offers, average traded price and circuit limits. Missing fields are left out."""
|
|
193
|
+
out = {
|
|
194
|
+
"buy_qty": _num(q.get("totBuyQuan")),
|
|
195
|
+
"sell_qty": _num(q.get("totSellQuan")),
|
|
196
|
+
"avg_price": _num(q.get("avgPrice")),
|
|
197
|
+
"last_qty": _num(q.get("lastTradeQty")),
|
|
198
|
+
"upper_circuit": _num(q.get("upperCircuit")),
|
|
199
|
+
"lower_circuit": _num(q.get("lowerCircuit")),
|
|
200
|
+
}
|
|
201
|
+
out = {k: v for k, v in out.items() if v is not None}
|
|
202
|
+
depth = q.get("depth") or {}
|
|
203
|
+
sides = {}
|
|
204
|
+
for side in ("buy", "sell"):
|
|
205
|
+
levels = [{"price": _num(lv.get("price")), "quantity": _num(lv.get("quantity")), "orders": _num(lv.get("orders"))}
|
|
206
|
+
for lv in depth.get(side) or [] if isinstance(lv, dict)]
|
|
207
|
+
# Empty levels come back as price 0 (no order at that rank).
|
|
208
|
+
sides[side] = [lv for lv in levels if lv["price"] and lv["quantity"]][:5]
|
|
209
|
+
if sides["buy"] or sides["sell"]:
|
|
210
|
+
out["depth"] = sides
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_master(rows: list[dict]) -> dict[str, str]:
|
|
215
|
+
"""NSE cash-market symbols (RELIANCE-EQ, NIFTYBEES-EQ, ...) -> token,
|
|
216
|
+
keyed by the plain symbol MarketMyna uses (RELIANCE)."""
|
|
217
|
+
out: dict[str, str] = {}
|
|
218
|
+
for r in rows:
|
|
219
|
+
# The live file says "NSE"; the docs' sample says "nse_cm".
|
|
220
|
+
if r.get("exch_seg") not in ("NSE", "nse_cm") or r.get("instrumenttype"):
|
|
221
|
+
continue
|
|
222
|
+
sym = str(r.get("symbol", ""))
|
|
223
|
+
for series in ("-EQ", "-BE", "-BZ", "-SM", "-ST"):
|
|
224
|
+
if sym.endswith(series):
|
|
225
|
+
base = sym[: -len(series)]
|
|
226
|
+
# Prefer the normal -EQ series when a symbol trades in several.
|
|
227
|
+
if base not in out or series == "-EQ":
|
|
228
|
+
out[base] = str(r.get("token"))
|
|
229
|
+
break
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _ist_date(epoch: float):
|
|
234
|
+
from datetime import datetime
|
|
235
|
+
from zoneinfo import ZoneInfo
|
|
236
|
+
|
|
237
|
+
return datetime.fromtimestamp(epoch, ZoneInfo("Asia/Kolkata")).date()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _feed_time(text: Optional[str]) -> Optional[float]:
|
|
241
|
+
from datetime import datetime
|
|
242
|
+
from zoneinfo import ZoneInfo
|
|
243
|
+
|
|
244
|
+
if not text:
|
|
245
|
+
return None
|
|
246
|
+
try:
|
|
247
|
+
return datetime.strptime(text, "%d-%b-%Y %H:%M:%S").replace(tzinfo=ZoneInfo("Asia/Kolkata")).timestamp()
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _num(value) -> Optional[float]:
|
|
253
|
+
try:
|
|
254
|
+
return float(value)
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_client: Optional[AngelClient] = None
|
|
260
|
+
_client_lock = threading.Lock()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def client(config) -> AngelClient:
|
|
264
|
+
"""Shared client; rebuilt when the credentials change."""
|
|
265
|
+
global _client
|
|
266
|
+
with _client_lock:
|
|
267
|
+
key = (config.angel_api_key, config.angel_client_code, config.angel_pin, config.angel_totp_secret)
|
|
268
|
+
if _client is None or getattr(_client, "_key", None) != key:
|
|
269
|
+
_client = AngelClient(config)
|
|
270
|
+
_client._key = key # type: ignore[attr-defined]
|
|
271
|
+
return _client
|