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,709 @@
|
|
|
1
|
+
"""Trade section: intraday chart data, price alerts, trade journal.
|
|
2
|
+
Read-only market data; nothing here places orders."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from marketmyna.api.deps import get_config_dep
|
|
13
|
+
from marketmyna.storage import (
|
|
14
|
+
DatabaseManager,
|
|
15
|
+
PriceAlertEventRecord,
|
|
16
|
+
PriceAlertRecord,
|
|
17
|
+
TradeRecord,
|
|
18
|
+
get_db_url,
|
|
19
|
+
)
|
|
20
|
+
from marketmyna.trading import chart as chart_mod
|
|
21
|
+
from marketmyna.trading.alerts import KINDS, AlertMonitor, describe
|
|
22
|
+
from marketmyna.trading.journal import r_multiple, stats, trade_pnl
|
|
23
|
+
from marketmyna.utils.deadline import call_with_deadline
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
MARKETS = {"us", "india", "china_a", "hong_kong", "crypto", "global"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _db(config):
|
|
31
|
+
return DatabaseManager.instance(get_db_url(config=config))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _row(rec) -> dict:
|
|
35
|
+
out = {}
|
|
36
|
+
for c in rec.__table__.columns:
|
|
37
|
+
v = getattr(rec, c.name)
|
|
38
|
+
out[c.name] = v.isoformat() if isinstance(v, datetime) else v
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_market(market: str) -> str:
|
|
43
|
+
market = market.lower()
|
|
44
|
+
if market not in MARKETS:
|
|
45
|
+
raise HTTPException(status_code=400, detail=f"Unknown market '{market}'")
|
|
46
|
+
return market
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------ pairs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/trade/pairs")
|
|
53
|
+
def trade_pairs(
|
|
54
|
+
a: str,
|
|
55
|
+
b: str,
|
|
56
|
+
market: str = "us",
|
|
57
|
+
market_b: Optional[str] = None,
|
|
58
|
+
period: Literal["1y", "2y", "5y"] = "2y",
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""Engle-Granger cointegration test, hedge ratio, half-life and spread z-score."""
|
|
61
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
62
|
+
|
|
63
|
+
from marketmyna.trading.pairs import analyze
|
|
64
|
+
|
|
65
|
+
market = _check_market(market)
|
|
66
|
+
market_b = _check_market(market_b or market)
|
|
67
|
+
a, b = a.strip().upper(), b.strip().upper()
|
|
68
|
+
if a == b and market == market_b:
|
|
69
|
+
raise HTTPException(status_code=400, detail="Pick two different symbols")
|
|
70
|
+
|
|
71
|
+
def bars(sym, mkt):
|
|
72
|
+
return call_with_deadline(chart_mod.fetch_bars, 30, sym, mkt, "1d", period)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
76
|
+
fa, fb = pool.submit(bars, a, market), pool.submit(bars, b, market_b)
|
|
77
|
+
da, db = fa.result(), fb.result()
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise HTTPException(status_code=502, detail=f"Could not load prices: {exc}")
|
|
80
|
+
|
|
81
|
+
def closes(df):
|
|
82
|
+
if df is None or df.empty:
|
|
83
|
+
return None
|
|
84
|
+
col = "close" if "close" in df.columns else "Close"
|
|
85
|
+
s = df[col].astype(float)
|
|
86
|
+
s.index = s.index.tz_localize(None).normalize() if getattr(s.index, "tz", None) else s.index.normalize()
|
|
87
|
+
return s[~s.index.duplicated(keep="last")]
|
|
88
|
+
|
|
89
|
+
sa, sb = closes(da), closes(db)
|
|
90
|
+
if sa is None or sb is None:
|
|
91
|
+
raise HTTPException(status_code=404, detail=f"No price history for {a if sa is None else b}")
|
|
92
|
+
try:
|
|
93
|
+
out = analyze(sa, sb)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
96
|
+
return {"a": a, "b": b, "market": market, "market_b": market_b, "period": period, **out}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------ chart
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/trade/chart")
|
|
103
|
+
def trade_chart(
|
|
104
|
+
symbol: str,
|
|
105
|
+
market: str = "us",
|
|
106
|
+
interval: str = "5m",
|
|
107
|
+
period: Optional[str] = None,
|
|
108
|
+
indicators: str = Query(default="vwap,ema9,ema21,supertrend"),
|
|
109
|
+
) -> dict:
|
|
110
|
+
market = _check_market(market)
|
|
111
|
+
if interval not in chart_mod.INTERVALS:
|
|
112
|
+
raise HTTPException(status_code=400, detail=f"Interval must be one of {', '.join(chart_mod.INTERVALS)}")
|
|
113
|
+
period = chart_mod.clamp_period(interval, period)
|
|
114
|
+
wanted = [i for i in indicators.split(",") if i in chart_mod.INDICATORS]
|
|
115
|
+
try:
|
|
116
|
+
df = call_with_deadline(chart_mod.fetch_bars, 30, symbol.strip().upper(), market, interval, period)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
raise HTTPException(status_code=502, detail=f"Could not load {interval} bars: {exc}")
|
|
119
|
+
return {"symbol": symbol.upper(), "market": market, "interval": interval, "period": period,
|
|
120
|
+
**chart_mod.build_chart(df, interval, wanted)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.get("/trade/levels")
|
|
124
|
+
def trade_levels(symbol: str, market: str = "us") -> dict:
|
|
125
|
+
"""Buy / sell / stop levels (same maths as the smart report) for
|
|
126
|
+
one-click alerts."""
|
|
127
|
+
from marketmyna.services.smart_report import snapshot
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
row = call_with_deadline(snapshot, 25, symbol.strip().upper(), _check_market(market))
|
|
131
|
+
except TimeoutError as exc:
|
|
132
|
+
raise HTTPException(status_code=504, detail=str(exc))
|
|
133
|
+
if "error" in row:
|
|
134
|
+
raise HTTPException(status_code=502, detail=row["error"])
|
|
135
|
+
from marketmyna.core.compliance import CALL_FIELDS
|
|
136
|
+
|
|
137
|
+
# The volatility band and ranges only, no buy / sell / stop.
|
|
138
|
+
row = {k: v for k, v in row.items() if k not in CALL_FIELDS}
|
|
139
|
+
# "at its buy / sell point" is the same call in words.
|
|
140
|
+
row["reasons"] = [r for r in row.get("reasons") or [] if "buy point" not in r and "sell point" not in r]
|
|
141
|
+
return row
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------ alerts
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AlertIn(BaseModel):
|
|
148
|
+
symbol: str
|
|
149
|
+
market: str = "us"
|
|
150
|
+
kind: str
|
|
151
|
+
value: float
|
|
152
|
+
note: str = ""
|
|
153
|
+
repeat: bool = False
|
|
154
|
+
cooldown_minutes: int = Field(default=30, ge=0, le=1440)
|
|
155
|
+
expires_at: Optional[datetime] = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AlertPatch(BaseModel):
|
|
159
|
+
value: Optional[float] = None
|
|
160
|
+
note: Optional[str] = None
|
|
161
|
+
repeat: Optional[bool] = None
|
|
162
|
+
cooldown_minutes: Optional[int] = Field(default=None, ge=0, le=1440)
|
|
163
|
+
status: Optional[Literal["active", "paused"]] = None
|
|
164
|
+
expires_at: Optional[datetime] = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _alert_dict(rec: PriceAlertRecord) -> dict:
|
|
168
|
+
return {**_row(rec), "description": describe(rec.kind, rec.value)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.get("/trade/alert-kinds")
|
|
172
|
+
def alert_kinds() -> list[dict]:
|
|
173
|
+
return [{"kind": k, "label": label, "unit": unit} for k, (label, unit, _) in KINDS.items()]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.get("/trade/alerts")
|
|
177
|
+
def list_alerts(config=Depends(get_config_dep)) -> list[dict]:
|
|
178
|
+
with _db(config).get_session() as s:
|
|
179
|
+
recs = s.query(PriceAlertRecord).order_by(PriceAlertRecord.status, PriceAlertRecord.id.desc()).all()
|
|
180
|
+
return [_alert_dict(r) for r in recs]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.post("/trade/alerts", status_code=201)
|
|
184
|
+
def create_alert(body: AlertIn, config=Depends(get_config_dep)) -> dict:
|
|
185
|
+
if body.kind not in KINDS:
|
|
186
|
+
raise HTTPException(status_code=400, detail=f"Unknown alert kind '{body.kind}'")
|
|
187
|
+
symbol = body.symbol.strip().upper()
|
|
188
|
+
if not symbol:
|
|
189
|
+
raise HTTPException(status_code=400, detail="Symbol is required")
|
|
190
|
+
if KINDS[body.kind][1] in ("price", "x avg", "days") and body.value <= 0:
|
|
191
|
+
raise HTTPException(status_code=400, detail="Value must be above zero")
|
|
192
|
+
with _db(config).get_session() as s:
|
|
193
|
+
rec = PriceAlertRecord(
|
|
194
|
+
symbol=symbol, market=_check_market(body.market), kind=body.kind, value=body.value,
|
|
195
|
+
note=body.note.strip(), repeat=body.repeat, cooldown_minutes=body.cooldown_minutes,
|
|
196
|
+
expires_at=body.expires_at, status="active", armed=True,
|
|
197
|
+
)
|
|
198
|
+
s.add(rec)
|
|
199
|
+
s.flush()
|
|
200
|
+
return _alert_dict(rec)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.patch("/trade/alerts/{alert_id}")
|
|
204
|
+
def update_alert(alert_id: int, body: AlertPatch, config=Depends(get_config_dep)) -> dict:
|
|
205
|
+
with _db(config).get_session() as s:
|
|
206
|
+
rec = s.get(PriceAlertRecord, alert_id)
|
|
207
|
+
if rec is None:
|
|
208
|
+
raise HTTPException(status_code=404, detail="Alert not found")
|
|
209
|
+
for k, v in body.model_dump(exclude_unset=True).items():
|
|
210
|
+
setattr(rec, k, v)
|
|
211
|
+
if body.status == "active":
|
|
212
|
+
rec.armed = True # re-activating a fired alert arms it again
|
|
213
|
+
return _alert_dict(rec)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@router.delete("/trade/alerts/{alert_id}", status_code=204)
|
|
217
|
+
def delete_alert(alert_id: int, config=Depends(get_config_dep)) -> None:
|
|
218
|
+
with _db(config).get_session() as s:
|
|
219
|
+
rec = s.get(PriceAlertRecord, alert_id)
|
|
220
|
+
if rec is not None:
|
|
221
|
+
s.delete(rec)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@router.get("/trade/alerts/events")
|
|
225
|
+
def alert_events(limit: int = 50, config=Depends(get_config_dep)) -> list[dict]:
|
|
226
|
+
with _db(config).get_session() as s:
|
|
227
|
+
recs = (
|
|
228
|
+
s.query(PriceAlertEventRecord)
|
|
229
|
+
.order_by(PriceAlertEventRecord.created_at.desc(), PriceAlertEventRecord.id.desc())
|
|
230
|
+
.limit(max(1, min(500, limit)))
|
|
231
|
+
.all()
|
|
232
|
+
)
|
|
233
|
+
return [_row(r) for r in recs]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@router.post("/trade/alerts/check")
|
|
237
|
+
def check_alerts_now(request: Request, config=Depends(get_config_dep)) -> dict:
|
|
238
|
+
"""Run one alert check immediately, ignoring market hours."""
|
|
239
|
+
monitor = getattr(request.app.state, "alert_monitor", None) or AlertMonitor(config)
|
|
240
|
+
return monitor.tick(force=True)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@router.get("/trade/alerts/status")
|
|
244
|
+
def alert_monitor_status(request: Request) -> dict:
|
|
245
|
+
monitor = getattr(request.app.state, "alert_monitor", None)
|
|
246
|
+
return {"running": bool(monitor and monitor.is_running()),
|
|
247
|
+
"poll_seconds": getattr(monitor, "_poll", None)}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------ journal
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TradeIn(BaseModel):
|
|
254
|
+
symbol: str
|
|
255
|
+
market: str = "us"
|
|
256
|
+
side: Literal["long", "short"] = "long"
|
|
257
|
+
quantity: float = Field(gt=0)
|
|
258
|
+
entry_price: float = Field(gt=0)
|
|
259
|
+
entry_at: Optional[datetime] = None
|
|
260
|
+
exit_price: Optional[float] = Field(default=None, gt=0)
|
|
261
|
+
exit_at: Optional[datetime] = None
|
|
262
|
+
stop: Optional[float] = Field(default=None, gt=0)
|
|
263
|
+
target: Optional[float] = Field(default=None, gt=0)
|
|
264
|
+
fees: Optional[float] = Field(default=None, ge=0) # empty = estimate from the charges schedule
|
|
265
|
+
product: Optional[Literal["intraday", "delivery", "futures", "options"]] = None
|
|
266
|
+
setup: str = ""
|
|
267
|
+
notes: str = ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class TradePatch(BaseModel):
|
|
271
|
+
side: Optional[Literal["long", "short"]] = None
|
|
272
|
+
quantity: Optional[float] = Field(default=None, gt=0)
|
|
273
|
+
entry_price: Optional[float] = Field(default=None, gt=0)
|
|
274
|
+
entry_at: Optional[datetime] = None
|
|
275
|
+
exit_price: Optional[float] = Field(default=None, gt=0)
|
|
276
|
+
exit_at: Optional[datetime] = None
|
|
277
|
+
stop: Optional[float] = Field(default=None, gt=0)
|
|
278
|
+
target: Optional[float] = Field(default=None, gt=0)
|
|
279
|
+
fees: Optional[float] = Field(default=None, ge=0)
|
|
280
|
+
product: Optional[Literal["intraday", "delivery", "futures", "options"]] = None
|
|
281
|
+
setup: Optional[str] = None
|
|
282
|
+
notes: Optional[str] = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _auto_fees(rec: TradeRecord) -> None:
|
|
286
|
+
"""Keep estimated fees current: entry side while open, round trip once closed."""
|
|
287
|
+
from marketmyna.trading.charges import for_trade
|
|
288
|
+
|
|
289
|
+
if rec.fees_auto:
|
|
290
|
+
rec.fees = for_trade(_plain(rec))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _plain(rec: TradeRecord) -> dict:
|
|
294
|
+
return {c.name: getattr(rec, c.name) for c in rec.__table__.columns}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _trade_dict(rec: TradeRecord) -> dict:
|
|
298
|
+
plain = _plain(rec)
|
|
299
|
+
pnl, r = trade_pnl(plain), r_multiple(plain)
|
|
300
|
+
return {**_row(rec), "pnl": round(pnl, 2) if pnl is not None else None,
|
|
301
|
+
"r_multiple": round(r, 2) if r is not None else None}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@router.get("/trade/journal")
|
|
305
|
+
def journal(market: Optional[str] = None, config=Depends(get_config_dep)) -> dict:
|
|
306
|
+
with _db(config).get_session() as s:
|
|
307
|
+
q = s.query(TradeRecord)
|
|
308
|
+
if market:
|
|
309
|
+
q = q.filter(TradeRecord.market == _check_market(market))
|
|
310
|
+
recs = q.order_by(TradeRecord.entry_at.desc(), TradeRecord.id.desc()).all()
|
|
311
|
+
markets = sorted({m for (m,) in s.query(TradeRecord.market).distinct()})
|
|
312
|
+
return {"trades": [_trade_dict(r) for r in recs], "stats": stats([_plain(r) for r in recs]),
|
|
313
|
+
"markets": markets}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@router.post("/trade/journal", status_code=201)
|
|
317
|
+
def add_trade(body: TradeIn, config=Depends(get_config_dep)) -> dict:
|
|
318
|
+
data = body.model_dump()
|
|
319
|
+
data["symbol"] = data["symbol"].strip().upper()
|
|
320
|
+
data["market"] = _check_market(data["market"])
|
|
321
|
+
data["entry_at"] = data["entry_at"] or datetime.now()
|
|
322
|
+
if data["exit_price"] is not None and data["exit_at"] is None:
|
|
323
|
+
data["exit_at"] = datetime.now()
|
|
324
|
+
data["fees_auto"] = data["fees"] is None
|
|
325
|
+
data["fees"] = data["fees"] or 0.0
|
|
326
|
+
with _db(config).get_session() as s:
|
|
327
|
+
rec = TradeRecord(**data)
|
|
328
|
+
_auto_fees(rec)
|
|
329
|
+
s.add(rec)
|
|
330
|
+
s.flush()
|
|
331
|
+
return _trade_dict(rec)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.patch("/trade/journal/{trade_id}")
|
|
335
|
+
def update_trade(trade_id: int, body: TradePatch, config=Depends(get_config_dep)) -> dict:
|
|
336
|
+
with _db(config).get_session() as s:
|
|
337
|
+
rec = s.get(TradeRecord, trade_id)
|
|
338
|
+
if rec is None:
|
|
339
|
+
raise HTTPException(status_code=404, detail="Trade not found")
|
|
340
|
+
changes = body.model_dump(exclude_unset=True)
|
|
341
|
+
if "fees" in changes:
|
|
342
|
+
rec.fees_auto = changes["fees"] is None
|
|
343
|
+
changes["fees"] = changes["fees"] or 0.0
|
|
344
|
+
for k, v in changes.items():
|
|
345
|
+
setattr(rec, k, v)
|
|
346
|
+
if changes.get("exit_price") is not None and rec.exit_at is None:
|
|
347
|
+
rec.exit_at = datetime.now()
|
|
348
|
+
_auto_fees(rec)
|
|
349
|
+
return _trade_dict(rec)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@router.delete("/trade/journal/{trade_id}", status_code=204)
|
|
353
|
+
def delete_trade(trade_id: int, config=Depends(get_config_dep)) -> None:
|
|
354
|
+
with _db(config).get_session() as s:
|
|
355
|
+
rec = s.get(TradeRecord, trade_id)
|
|
356
|
+
if rec is not None:
|
|
357
|
+
s.delete(rec)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ------------------------------------------------------------------ scanner
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class Universe(BaseModel):
|
|
364
|
+
sources: list[Literal["top", "watchlist", "portfolio", "custom"]] = ["top"]
|
|
365
|
+
market: str = "india"
|
|
366
|
+
top_n: int = Field(default=100, ge=1, le=500)
|
|
367
|
+
watchlist_ids: list[int] = []
|
|
368
|
+
portfolio_names: list[str] = []
|
|
369
|
+
symbols: list[str] = []
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class ScanIn(BaseModel):
|
|
373
|
+
universe: Universe = Universe()
|
|
374
|
+
scans: list[str]
|
|
375
|
+
params: dict[str, float] = {}
|
|
376
|
+
match: Literal["any", "all"] = "any"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class SavedScanIn(ScanIn):
|
|
380
|
+
name: str
|
|
381
|
+
notify: bool = False
|
|
382
|
+
interval_minutes: int = Field(default=15, ge=5, le=1440)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class SavedScanPatch(BaseModel):
|
|
386
|
+
name: Optional[str] = None
|
|
387
|
+
universe: Optional[Universe] = None
|
|
388
|
+
scans: Optional[list[str]] = None
|
|
389
|
+
params: Optional[dict[str, float]] = None
|
|
390
|
+
match: Optional[Literal["any", "all"]] = None
|
|
391
|
+
notify: Optional[bool] = None
|
|
392
|
+
interval_minutes: Optional[int] = Field(default=None, ge=5, le=1440)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _clean_scans(scans: list[str]) -> list[str]:
|
|
396
|
+
from marketmyna.trading.scanner import SCANS
|
|
397
|
+
|
|
398
|
+
clean = [s for s in dict.fromkeys(scans) if s in SCANS]
|
|
399
|
+
if not clean:
|
|
400
|
+
raise HTTPException(status_code=400, detail="Pick at least one scan")
|
|
401
|
+
return clean
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@router.get("/trade/scans/catalog")
|
|
405
|
+
def scan_catalog() -> list[dict]:
|
|
406
|
+
from marketmyna.trading.scanner import SCANS
|
|
407
|
+
|
|
408
|
+
return [
|
|
409
|
+
{"id": k, "label": label, "group": group, "intraday": intraday, "default": default, "param": param}
|
|
410
|
+
for k, (label, group, intraday, default, param) in SCANS.items()
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@router.post("/trade/scan")
|
|
415
|
+
def run_scan_now(body: ScanIn, config=Depends(get_config_dep)) -> dict:
|
|
416
|
+
from marketmyna.trading.scanner import resolve_universe, run_scan
|
|
417
|
+
|
|
418
|
+
universe = body.universe.model_dump()
|
|
419
|
+
universe["market"] = _check_market(universe["market"])
|
|
420
|
+
targets, names = resolve_universe(universe, config)
|
|
421
|
+
if not targets:
|
|
422
|
+
raise HTTPException(status_code=400, detail="The chosen universe has no stocks")
|
|
423
|
+
try:
|
|
424
|
+
return call_with_deadline(run_scan, 170, targets, _clean_scans(body.scans), body.params, body.match, names=names)
|
|
425
|
+
except TimeoutError as exc:
|
|
426
|
+
raise HTTPException(status_code=504, detail=f"Scan timed out: {exc}")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@router.get("/trade/scans/saved")
|
|
430
|
+
def list_saved_scans(config=Depends(get_config_dep)) -> list[dict]:
|
|
431
|
+
from marketmyna.storage import SavedScanRecord
|
|
432
|
+
|
|
433
|
+
with _db(config).get_session() as s:
|
|
434
|
+
return [_row(r) for r in s.query(SavedScanRecord).order_by(SavedScanRecord.id)]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@router.post("/trade/scans/saved", status_code=201)
|
|
438
|
+
def save_scan(body: SavedScanIn, config=Depends(get_config_dep)) -> dict:
|
|
439
|
+
from marketmyna.storage import SavedScanRecord
|
|
440
|
+
|
|
441
|
+
if not body.name.strip():
|
|
442
|
+
raise HTTPException(status_code=400, detail="Name is required")
|
|
443
|
+
with _db(config).get_session() as s:
|
|
444
|
+
rec = SavedScanRecord(
|
|
445
|
+
name=body.name.strip(), universe=body.universe.model_dump(), scans=_clean_scans(body.scans),
|
|
446
|
+
params=body.params, match=body.match, notify=body.notify, interval_minutes=body.interval_minutes,
|
|
447
|
+
)
|
|
448
|
+
s.add(rec)
|
|
449
|
+
s.flush()
|
|
450
|
+
return _row(rec)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@router.patch("/trade/scans/saved/{scan_id}")
|
|
454
|
+
def update_saved_scan(scan_id: int, body: SavedScanPatch, config=Depends(get_config_dep)) -> dict:
|
|
455
|
+
from marketmyna.storage import SavedScanRecord
|
|
456
|
+
|
|
457
|
+
with _db(config).get_session() as s:
|
|
458
|
+
rec = s.get(SavedScanRecord, scan_id)
|
|
459
|
+
if rec is None:
|
|
460
|
+
raise HTTPException(status_code=404, detail="Saved scan not found")
|
|
461
|
+
changes = body.model_dump(exclude_unset=True)
|
|
462
|
+
if "scans" in changes:
|
|
463
|
+
changes["scans"] = _clean_scans(changes["scans"])
|
|
464
|
+
for k, v in changes.items():
|
|
465
|
+
setattr(rec, k, v)
|
|
466
|
+
return _row(rec)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@router.delete("/trade/scans/saved/{scan_id}", status_code=204)
|
|
470
|
+
def delete_saved_scan(scan_id: int, config=Depends(get_config_dep)) -> None:
|
|
471
|
+
from marketmyna.storage import SavedScanRecord
|
|
472
|
+
|
|
473
|
+
with _db(config).get_session() as s:
|
|
474
|
+
rec = s.get(SavedScanRecord, scan_id)
|
|
475
|
+
if rec is not None:
|
|
476
|
+
s.delete(rec)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ------------------------------------------------------------------ daily risk
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class RiskSettingsIn(BaseModel):
|
|
483
|
+
market: Optional[str] = None
|
|
484
|
+
capital: Optional[float] = Field(default=None, gt=0)
|
|
485
|
+
daily_loss_limit_pct: Optional[float] = Field(default=None, ge=0, le=100)
|
|
486
|
+
max_open_risk_pct: Optional[float] = Field(default=None, ge=0, le=100)
|
|
487
|
+
max_trades_per_day: Optional[int] = Field(default=None, ge=0, le=1000)
|
|
488
|
+
notify: Optional[bool] = None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@router.get("/trade/risk")
|
|
492
|
+
def risk_status(config=Depends(get_config_dep)) -> dict:
|
|
493
|
+
from marketmyna.trading.risk import status
|
|
494
|
+
|
|
495
|
+
return status(config)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@router.put("/trade/risk/settings")
|
|
499
|
+
def update_risk_settings(body: RiskSettingsIn, config=Depends(get_config_dep)) -> dict:
|
|
500
|
+
from marketmyna.trading.risk import save_settings
|
|
501
|
+
|
|
502
|
+
changes = body.model_dump(exclude_unset=True)
|
|
503
|
+
if "market" in changes:
|
|
504
|
+
changes["market"] = _check_market(changes["market"])
|
|
505
|
+
return save_settings(config, **changes)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------ backtest
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class BacktestIn(BaseModel):
|
|
512
|
+
symbol: str
|
|
513
|
+
market: str = "india"
|
|
514
|
+
interval: Literal["1d", "1wk", "1h"] = "1d"
|
|
515
|
+
period: Literal["1y", "2y", "5y", "10y", "max"] = "5y"
|
|
516
|
+
strategy: str = "ema_cross"
|
|
517
|
+
params: dict[str, float] = {}
|
|
518
|
+
capital: float = Field(default=100000, gt=0)
|
|
519
|
+
sizing: Literal["percent", "risk"] = "percent"
|
|
520
|
+
position_pct: float = Field(default=100, gt=0, le=100)
|
|
521
|
+
risk_pct: float = Field(default=1, gt=0, le=100)
|
|
522
|
+
stop_type: Literal["none", "percent", "atr"] = "none"
|
|
523
|
+
stop_value: float = Field(default=2, gt=0)
|
|
524
|
+
target_r: Optional[float] = Field(default=None, gt=0)
|
|
525
|
+
cost_pct: float = Field(default=0.1, ge=0, le=5)
|
|
526
|
+
slippage_pct: float = Field(default=0.05, ge=0, le=5)
|
|
527
|
+
allow_short: bool = False
|
|
528
|
+
optimize: bool = False
|
|
529
|
+
objective: Literal["sharpe", "total_return", "profit_factor", "cagr"] = "sharpe"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@router.get("/trade/backtest/strategies")
|
|
533
|
+
def backtest_strategies() -> list[dict]:
|
|
534
|
+
from marketmyna.trading.backtest import STRATEGIES
|
|
535
|
+
|
|
536
|
+
return [{"id": k, **v} for k, v in STRATEGIES.items()]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@router.post("/trade/backtest")
|
|
540
|
+
def backtest(body: BacktestIn) -> dict:
|
|
541
|
+
from marketmyna.trading import backtest as bt
|
|
542
|
+
|
|
543
|
+
if body.strategy not in bt.STRATEGIES:
|
|
544
|
+
raise HTTPException(status_code=400, detail=f"Unknown strategy '{body.strategy}'")
|
|
545
|
+
market = _check_market(body.market)
|
|
546
|
+
period = "2y" if body.interval == "1h" and body.period not in ("1y", "2y") else body.period
|
|
547
|
+
try:
|
|
548
|
+
df = chart_mod.normalize(call_with_deadline(chart_mod.fetch_bars, 45, body.symbol.strip().upper(), market, body.interval, period))
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
raise HTTPException(status_code=502, detail=f"Could not load history: {exc}")
|
|
551
|
+
if len(df) < 60:
|
|
552
|
+
raise HTTPException(status_code=400, detail=f"Only {len(df)} bars of history; need at least 60")
|
|
553
|
+
settings = bt.Settings(
|
|
554
|
+
strategy=body.strategy, params=body.params, capital=body.capital, sizing=body.sizing,
|
|
555
|
+
position_pct=body.position_pct, risk_pct=body.risk_pct, stop_type=body.stop_type,
|
|
556
|
+
stop_value=body.stop_value, target_r=body.target_r, cost_pct=body.cost_pct,
|
|
557
|
+
slippage_pct=body.slippage_pct, allow_short=body.allow_short,
|
|
558
|
+
)
|
|
559
|
+
out: dict = {"symbol": body.symbol.upper(), "market": market, "interval": body.interval, "period": period}
|
|
560
|
+
if body.optimize:
|
|
561
|
+
opt = bt.optimize(df, settings, body.interval, body.objective)
|
|
562
|
+
out["optimization"] = opt
|
|
563
|
+
settings.params = opt["best"]
|
|
564
|
+
out["params"] = {**bt.STRATEGIES[body.strategy]["params"], **settings.params}
|
|
565
|
+
out.update(bt.run(df, settings, body.interval))
|
|
566
|
+
out["split"] = bt.split_test(df, settings, body.interval)
|
|
567
|
+
return out
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ------------------------------------------------------------------ options
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class Leg(BaseModel):
|
|
574
|
+
type: Literal["call", "put", "stock"]
|
|
575
|
+
side: Literal["buy", "sell"]
|
|
576
|
+
strike: float = 0
|
|
577
|
+
premium: float = Field(default=0, ge=0)
|
|
578
|
+
quantity: float = Field(gt=0)
|
|
579
|
+
iv: Optional[float] = Field(default=None, gt=0, le=500)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class PayoffIn(BaseModel):
|
|
583
|
+
legs: list[Leg] = Field(min_length=1, max_length=8)
|
|
584
|
+
spot: float = Field(gt=0)
|
|
585
|
+
days: float = Field(default=7, ge=0, le=1000)
|
|
586
|
+
market: str = "india"
|
|
587
|
+
width_pct: float = Field(default=15, gt=1, le=80)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@router.get("/trade/options/chain")
|
|
591
|
+
def option_chain(symbol: str, market: str = "india", expiry: Optional[str] = None) -> dict:
|
|
592
|
+
from marketmyna.trading import options
|
|
593
|
+
|
|
594
|
+
market = _check_market(market)
|
|
595
|
+
if market in ("china_a", "crypto"):
|
|
596
|
+
raise HTTPException(status_code=400, detail="Option chains are available for India (NSE) and US/global (Yahoo)")
|
|
597
|
+
try:
|
|
598
|
+
return call_with_deadline(options.chain, 40, symbol.strip().upper(), market, expiry)
|
|
599
|
+
except ValueError as exc:
|
|
600
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
601
|
+
except Exception as exc:
|
|
602
|
+
raise HTTPException(status_code=502, detail=f"Option chain unavailable: {exc}")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@router.post("/trade/options/payoff")
|
|
606
|
+
def option_payoff(body: PayoffIn) -> dict:
|
|
607
|
+
from marketmyna.trading import options
|
|
608
|
+
|
|
609
|
+
legs = [leg.model_dump() for leg in body.legs]
|
|
610
|
+
for leg in legs:
|
|
611
|
+
if leg["type"] != "stock" and leg["strike"] <= 0:
|
|
612
|
+
raise HTTPException(status_code=400, detail="Every option leg needs a strike")
|
|
613
|
+
rate = options.RATES.get(_check_market(body.market), 0.05)
|
|
614
|
+
return options.payoff(legs, body.spot, body.days, rate, body.width_pct)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ------------------------------------------------------------------ live quotes (SSE)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
STREAM_SECONDS = 15
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@router.get("/trade/stream")
|
|
624
|
+
async def quote_stream(request: Request, symbols: str, every: int = 10):
|
|
625
|
+
"""Server-sent events: `{"symbol", "market", "price", "change_percent",
|
|
626
|
+
"volume", "time"}` for each `market:SYMBOL` in `symbols`, every `every`
|
|
627
|
+
seconds (min 5). Stock prices carry the source's delay (~15 min on free
|
|
628
|
+
data); crypto is live."""
|
|
629
|
+
import asyncio
|
|
630
|
+
import json
|
|
631
|
+
|
|
632
|
+
from fastapi.responses import StreamingResponse
|
|
633
|
+
|
|
634
|
+
from marketmyna.trading.alerts import _default_quote
|
|
635
|
+
from marketmyna.trading.market_hours import is_open
|
|
636
|
+
|
|
637
|
+
pairs = []
|
|
638
|
+
for item in symbols.split(",")[:20]:
|
|
639
|
+
if ":" in item:
|
|
640
|
+
mkt, sym = item.split(":", 1)
|
|
641
|
+
pairs.append((sym.strip().upper(), _check_market(mkt)))
|
|
642
|
+
if not pairs:
|
|
643
|
+
raise HTTPException(status_code=400, detail="symbols must look like india:RELIANCE,crypto:BTC-USD")
|
|
644
|
+
every = max(5, min(300, every))
|
|
645
|
+
|
|
646
|
+
async def events():
|
|
647
|
+
# Each connection lives ~15s, then the browser's EventSource reconnects
|
|
648
|
+
# (after `retry` ms). A never-ending response would otherwise block the
|
|
649
|
+
# server's graceful shutdown - and with it every --reload - for as long
|
|
650
|
+
# as a Trade page is open.
|
|
651
|
+
import time
|
|
652
|
+
|
|
653
|
+
started = time.monotonic()
|
|
654
|
+
yield "retry: 1000\n\n"
|
|
655
|
+
while not await request.is_disconnected() and time.monotonic() - started < STREAM_SECONDS:
|
|
656
|
+
for sym, mkt in pairs:
|
|
657
|
+
try:
|
|
658
|
+
q = await asyncio.to_thread(call_with_deadline, _default_quote, 15, sym, mkt)
|
|
659
|
+
except Exception as exc:
|
|
660
|
+
yield f"event: error\ndata: {json.dumps({'symbol': sym, 'error': str(exc)})}\n\n"
|
|
661
|
+
continue
|
|
662
|
+
payload = {"symbol": sym, "market": mkt, "open": is_open(mkt),
|
|
663
|
+
"price": q.get("price"), "change_percent": q.get("change_percent"),
|
|
664
|
+
"day_high": q.get("day_high"), "day_low": q.get("day_low"), "volume": q.get("volume"),
|
|
665
|
+
"time": datetime.now().isoformat(timespec="seconds")}
|
|
666
|
+
yield f"data: {json.dumps(payload)}\n\n"
|
|
667
|
+
await asyncio.sleep(every)
|
|
668
|
+
|
|
669
|
+
return StreamingResponse(events(), media_type="text/event-stream",
|
|
670
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ------------------------------------------------------------------ charges
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class ChargesIn(BaseModel):
|
|
677
|
+
market: str = "india"
|
|
678
|
+
product: Optional[Literal["intraday", "delivery", "futures", "options"]] = None
|
|
679
|
+
buy_value: float = Field(default=0, ge=0)
|
|
680
|
+
sell_value: float = Field(default=0, ge=0)
|
|
681
|
+
quantity: float = Field(default=0, ge=0)
|
|
682
|
+
commission_pct: float = Field(default=0, ge=0, le=10)
|
|
683
|
+
flat: float = Field(default=0, ge=0)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@router.post("/trade/charges")
|
|
687
|
+
def trade_charges(body: ChargesIn) -> dict:
|
|
688
|
+
from marketmyna.trading.charges import estimate
|
|
689
|
+
|
|
690
|
+
return estimate(_check_market(body.market), body.buy_value, body.sell_value, body.quantity, body.product,
|
|
691
|
+
body.commission_pct, body.flat)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@router.get("/trade/lot-size")
|
|
695
|
+
def trade_lot_size(symbol: str) -> dict:
|
|
696
|
+
"""NSE F&O lot size (nearest expiry) from the brokers' instrument files."""
|
|
697
|
+
from marketmyna.trading.lots import lot_size
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
return {"symbol": symbol.upper(), "lot_size": lot_size(symbol.strip())}
|
|
701
|
+
except Exception as exc:
|
|
702
|
+
raise HTTPException(status_code=502, detail=f"Lot sizes unavailable: {exc}")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@router.get("/trade/market-status")
|
|
706
|
+
def market_status(market: str = "india") -> dict:
|
|
707
|
+
from marketmyna.trading.market_hours import status
|
|
708
|
+
|
|
709
|
+
return status(_check_market(market))
|