diff --git a/.env.example b/.env.example index f7c7d5f..9602059 100644 --- a/.env.example +++ b/.env.example @@ -22,13 +22,37 @@ SKOPAQ_SUPABASE_SERVICE_KEY=eyJ... SKOPAQ_UPSTASH_REDIS_URL=https://your-redis.upstash.io SKOPAQ_UPSTASH_REDIS_TOKEN=AX... +# ===== Broker Selection ===== +# indstocks = INDstocks, kite = Kite Connect (Zerodha) +SKOPAQ_BROKER=indstocks + # ===== INDstocks Broker ===== SKOPAQ_INDSTOCKS_TOKEN= SKOPAQ_INDSTOCKS_BASE_URL=https://api.indstocks.com +# ===== Kite Connect (Zerodha) Broker ===== +SKOPAQ_KITE_API_KEY= +SKOPAQ_KITE_API_SECRET= +SKOPAQ_KITE_ACCESS_TOKEN= + +# ===== Angel One SmartAPI (free real-time market data) ===== +# Free account at angelone.com, API key at smartapi.angelbroking.com +SKOPAQ_ANGELONE_API_KEY= +SKOPAQ_ANGELONE_CLIENT_ID= +SKOPAQ_ANGELONE_PASSWORD= +SKOPAQ_ANGELONE_TOTP_SECRET= + +# ===== Upstox API (free real-time market data) ===== +# Free account at upstox.com, app at upstox.com/developer/apps +SKOPAQ_UPSTOX_API_KEY= +SKOPAQ_UPSTOX_API_SECRET= +SKOPAQ_UPSTOX_ACCESS_TOKEN= + # ===== Trading Mode ===== -# paper = simulated trades, live = real money via INDstocks +# paper = simulated trades, live = real money via configured broker SKOPAQ_TRADING_MODE=paper +# CNC = delivery (hold overnight), MIS = intraday (squared off by 3:15 PM) +SKOPAQ_DEFAULT_PRODUCT=CNC # ===== Asset Class ===== # equity = Indian equities (NSE/BSE), crypto = Binance Spot diff --git a/CLAUDE.md b/CLAUDE.md index 229102e..c36b2ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides context for AI coding agents working on the SkopaqTrader code ## Project Overview -AI algorithmic trading platform for Indian equities. Built on vendored [TradingAgents v0.2.0](https://github.com/TauricResearch/TradingAgents) (Apache 2.0) with a custom `skopaq/` layer for INDstocks broker integration, multi-model LLM tiering, and an autonomous execution pipeline. +AI algorithmic trading platform for Indian equities. Built on vendored [TradingAgents v0.2.0](https://github.com/TauricResearch/TradingAgents) (Apache 2.0) with a custom `skopaq/` layer for multi-broker integration (INDstocks + Kite Connect), multi-model LLM tiering, and an autonomous execution pipeline. ## Architecture @@ -17,8 +17,8 @@ Two codebases in one repo: ``` CLI/API → SkopaqTradingGraph → [upstream LangGraph agents] → TradeSignal - → SafetyChecker → PositionSizer → OrderRouter → INDstocks/Paper - → PositionMonitor → SellAnalyst → exit + → SafetyChecker → PositionSizer → OrderRouter → Broker(INDstocks|Kite)/Paper + → PositionMonitor(MarketDataProvider) → SellAnalyst → exit ``` ### Daemon Flow (autonomous) @@ -44,11 +44,21 @@ python3 -m pytest tests/integration/ -v -m integration # CLI commands skopaq status # Health check skopaq analyze RELIANCE # Analysis only -skopaq trade RELIANCE # Analysis + execution (paper default) -skopaq scan # Scanner cycle +skopaq trade RELIANCE # Trade specific symbol (full lifecycle) +skopaq trade # Auto-scan → trade best pick → monitor → close +skopaq trade --top 3 # Auto-scan → trade top 3 → monitor → close +skopaq trade --no-monitor # Trade without position monitoring +skopaq scan # Scanner cycle (no execution) skopaq daemon --once --paper # Full autonomous session skopaq monitor # Monitor existing positions skopaq serve # FastAPI server + +# Kite Connect (Zerodha) broker +skopaq kite login-url # Print OAuth login URL +skopaq kite session # Exchange request_token for access_token +skopaq kite set-token # Store access_token directly +skopaq kite status # Check Kite token health +skopaq kite clear # Delete stored Kite token ``` ## Configuration @@ -73,6 +83,10 @@ skopaq serve # FastAPI server **Critical:** Gemini 3 returns `response.content` as a list of dicts, not a string. Always use `skopaq.llm.extract_text()` to normalize. +## Broker Selection + +Set `SKOPAQ_BROKER=indstocks` (default) or `SKOPAQ_BROKER=kite` to choose the broker. The `OrderRouter` dispatches to either client via a `BrokerClient` protocol. Paper mode works without any broker credentials — the `MarketDataProvider` falls back to yfinance. + ## INDstocks API - ALL market data endpoints use `scrip-codes=NSE_2885` format (NOT `symbols=NSE:RELIANCE`) @@ -82,13 +96,40 @@ skopaq serve # FastAPI server - Quote fields: `live_price`, `day_open`, `day_high`, `day_low`, `prev_close` - Always refer to `docs/indstocks_api.md` for endpoint reference — do not assume +## Kite Connect API + +- Auth header: `Authorization: token api_key:access_token` +- Quote endpoint: `GET /quote?i=NSE:RELIANCE` (instrument key format `EXCHANGE:SYMBOL`) +- Orders: `POST /orders/{variety}` where variety = `regular`, `amo`, `co`, `iceberg` +- Product types: `CNC` (delivery), `MIS` (intraday), `NRML` (F&O) +- Positions: `GET /portfolio/positions` returns `{"net": [...], "day": [...]}` +- Historical: `GET /instruments/historical/{token}/{interval}` with datetime strings +- Instruments: `GET /instruments/NSE` returns CSV with `instrument_token`, `tradingsymbol` +- Token lifecycle: OAuth2 flow → `access_token` valid until ~6:00 AM IST next day +- CLI: `skopaq kite login-url`, `skopaq kite session `, `skopaq kite status` + +## MarketDataProvider + +`skopaq/broker/market_data.py` — unified async market data layer used by paper engine and position monitor. Tries sources in priority order: + +1. **Broker API** (INDstocks or Kite) — best data, real bid/ask +2. **Angel One SmartAPI** (free, real-time) — real bid/ask, 5-level depth +3. **Upstox API** (free, real-time) — real bid/ask, backup source +4. **yfinance** (no credentials) — free fallback, delayed, synthetic bid/ask +5. **Binance public API** — for crypto (no auth needed) +6. **Stale cache** — last resort, returns last known quote + +Angel One and Upstox auto-initialize on first use if credentials are configured. No code changes needed — just set env vars. + +Paper mode auto-refreshes quotes before every fill via `PaperEngine.execute_order_async()`. + ## File Organization ``` skopaq/ ├── agents/ # Sell analyst (AI exit decisions) ├── api/ # FastAPI backend -├── broker/ # INDstocks REST/WS + Binance + paper engine +├── broker/ # INDstocks + Kite Connect + Binance + paper engine + market data provider ├── cli/ # Typer CLI (main.py = all commands, display.py = Rich output) ├── db/ # Supabase client + repositories ├── execution/ # Executor, safety checker, order router, daemon, position monitor @@ -112,7 +153,8 @@ skopaq/ ## Key Conventions 1. **Safety rules are immutable** — `SafetyRules` in `constants.py` cannot be overridden at runtime. The `SafetyChecker` enforces them before every order. -2. **Paper mode is default** — All CLI commands default to paper trading. Live mode requires explicit `--live` or `SKOPAQ_TRADING_MODE=live` + confirmation prompt. +2. **Paper mode is default** — All CLI commands default to paper trading. Paper mode works without broker credentials (yfinance fallback). Live mode requires explicit `--live` or `SKOPAQ_TRADING_MODE=live` + confirmation prompt. +7. **MarketDataProvider for paper mode** — The `PaperEngine` uses `MarketDataProvider` to auto-refresh quotes before fills. Use `execute_order_async()` (not `execute_order()`) when a provider is attached. The `PositionMonitor` uses `MarketDataProvider` for LTP polling (not a broker client directly). 3. **Upstream modifications are minimal** — Changes to `tradingagents/` must be documented in `UPSTREAM_CHANGES.md` with backward-compatibility notes. 4. **No secrets in code** — All credentials come from environment variables. Never commit `.env`, token files, or API keys. 5. **Pydantic v2 models** — Broker models in `skopaq/broker/models.py` use Pydantic v2. Use attribute access (`model.field`), not dict access (`model["field"]` or `model.get("field")`). @@ -125,6 +167,9 @@ skopaq/ - **yfinance suffixes**: LLM agents generate `RELIANCE.NS` (Yahoo Finance convention). The routing layer adds/strips suffixes automatically, but new data flows must handle this. - **Local imports in daemon.py**: Many imports are inside method bodies to avoid circular imports. This affects mock patch targets in tests (see Testing Patterns above). - **Stop event propagation**: The daemon's `stop_event` must be checked before every major operation, not just inside delay waits. +- **Broker client type**: `OrderRouter` uses a `BrokerClient` protocol — both `INDstocksClient` and `KiteConnectClient` satisfy it. Don't use `isinstance` checks; use `config.broker` to dispatch. +- **Kite token expiry**: Kite tokens expire at ~6:00 AM IST daily (not rolling 24h like INDstocks). The `KiteTokenManager` calculates expiry to next 6 AM. +- **PositionMonitor no longer takes a broker client** — it takes a `MarketDataProvider` instead. The old `client` parameter is ignored (backward compat). ## Deployment diff --git a/README.md b/README.md index e4343c2..bf75bbb 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,8 @@ SkopaqTrader extends the [TradingAgents](https://github.com/TauricResearch/Tradi - **Multi-model tiering** — Per-role LLM assignment across multiple providers for cost optimization and capability matching. See the [model tiering table](#multi-model-tiering) below for details. - **Semantic LLM Caching** — Built-in Redis LangCache provides significant speedup (up to ~45x in our benchmarks) on repeated queries and reduces API costs, with automatic semantic invalidation on memory updates. - **Advanced Risk Management** — Features ATR-based position sizing, India VIX/NIFTY SMA market regime detection, NSE event calendar handling (F&O expiry, RBI policy), and sector concentration limits. -- **Live Algo Trading** — Integrates with the INDstocks broker API for execution on Indian equities (NSE/BSE). Start in paper mode, graduate to live when ready. +- **Live Algo Trading** — Integrates with INDstocks and Kite Connect (Zerodha) broker APIs for execution on Indian equities (NSE/BSE). Start in paper mode, graduate to live when ready. +- **Live-Data Paper Trading** — Paper mode uses real market data from any source (broker API, yfinance, Binance public) with automatic fallback. No broker credentials required to paper trade. - **Confidence-Scored Position Sizing** — The Risk Manager evaluates trades with strict confidence scores (50-100%). Position sizes are dynamically scaled based on this AI confidence level. - **Parallel Scanner Engine** — 30-second multi-model screening cycle on the NIFTY 50 watchlist, wired directly to INDstocks batch quotes and 3 LLM screeners (Gemini, Grok, Perplexity) running concurrently. - **Safety-First Execution** — Immutable position limits, persistent drawdown tracking, daily loss circuit breakers, and small-account exemptions. @@ -87,7 +88,7 @@ graph TD end subgraph Infra["Infrastructure"] - INDstocks["INDstocks Broker
NSE / BSE Trading"]:::external + Broker["INDstocks / Kite Connect
NSE / BSE Trading"]:::external Supabase["Supabase DB
State, History, Auth"]:::external Redis["Redis LangCache
Semantic LLM Caching"]:::external end @@ -101,14 +102,14 @@ graph TD RiskAgent -- "Confidence %" --> TraderAgent TraderAgent --> Safety Safety --> Router - Router --> INDstocks + Router --> Broker Orchestrator -.-> Supabase Orchestrator -.-> Redis Router -. "Trade Result" .-> Orchestrator Orchestrator -. "Reflection" .-> DataAgents ``` -*High-level overview of the SkopaqTrader architecture, connecting the user interfaces to the multi-agent AI team and the INDstocks execution engine.* +*High-level overview of the SkopaqTrader architecture, connecting the user interfaces to the multi-agent AI team and the broker execution engine.* ### 🤖 AI Agent Workflow @@ -124,7 +125,7 @@ sequenceDiagram participant Research as Researchers participant Risk as Risk Manager participant Trader as Trader Agent - participant Broker as INDstocks Broker + participant Broker as Broker (INDstocks / Kite) User->>Orch: Request Analysis (e.g. RELIANCE) Orch->>Analysts: Gather Market, News, Social Data @@ -160,7 +161,7 @@ flowchart LR S2["2. AI Team Analyzes
News, Trends, Fundamentals"]:::step S3["3. AI Debate and Decision
Bull vs Bear Arguments"]:::step S4["4. Risk and Confidence Check
Score validates position size"]:::step - S5["5. Execute Trade
Paper or Live via INDstocks"]:::highlight + S5["5. Execute Trade
Paper or Live via Broker"]:::highlight S6["6. Reflect and Learn
Lessons feed future trades"]:::step S1 --> S2 --> S3 --> S4 --> S5 -.-> S6 @@ -186,7 +187,7 @@ flowchart TD classDef memory fill:#475569,stroke:#334155,color:#fff Start(("skopaq scan
NIFTY 50")):::trigger - INDapi["INDstocks API Batch Quote"]:::data + INDapi["Broker API Batch Quote
(INDstocks / Kite / yfinance)"]:::data CacheCheck{"Redis LangCache
Semantic Check"}:::check CachedData["Return Cached Inference"]:::llm @@ -247,6 +248,7 @@ SkopaqTrader includes comprehensive blockchain infrastructure for crypto trading | Feature | Module | Description | |---------|--------|-------------| +| **Kite Connect** | `skopaq/broker/kite_client.py` | Zerodha Kite Connect REST API v3 for NSE/BSE trading | | **Live Trading** | `skopaq/broker/binance_auth.py` | Authenticated Binance API for spot trading with API keys | | **Real-Time Feeds** | `skopaq/broker/binance_ws.py` | WebSocket streams for ticker, trades, order book, klines | | **Gas Oracle** | `skopaq/blockchain/gas.py` | ETH, Polygon, Arbitrum, Optimism gas prices + tx cost estimates | @@ -333,10 +335,16 @@ skopaq status skopaq analyze RELIANCE skopaq analyze TATAMOTORS --date 2026-02-28 -# Analyze + execute (paper mode by default) +# Trade a specific stock (full lifecycle: analyze + trade + monitor + close) skopaq trade RELIANCE -# Run scanner cycle +# Auto-discover and trade (scan NIFTY 50 → pick best → trade → monitor) +skopaq trade # Best single pick +skopaq trade --top 3 # Trade top 3 candidates +skopaq trade --watchlist "RELIANCE,TCS,INFY" # Scan only these symbols +skopaq trade --no-monitor # Skip position monitoring + +# Run scanner cycle (no execution) skopaq scan --max-candidates 5 # Autonomous daemon (full session: scan → trade → monitor → close) @@ -354,8 +362,24 @@ skopaq serve --port 8000 # Token management (INDstocks broker) skopaq token set skopaq token status + +# Kite Connect (Zerodha) broker +skopaq kite login-url # Get OAuth login URL +skopaq kite session # Exchange for access token +skopaq kite status # Check Kite token health +``` + +### Broker Selection + +Set `SKOPAQ_BROKER` in `.env` to choose your broker: + +```bash +SKOPAQ_BROKER=indstocks # INDstocks (default) +SKOPAQ_BROKER=kite # Kite Connect (Zerodha) ``` +Paper trading works **without any broker credentials** — the `MarketDataProvider` automatically falls back to yfinance for market data. + ### Python API ```python @@ -406,7 +430,7 @@ skopaqtrader/ │ ├── agents/ # Sell analyst (AI exit decisions) │ ├── api/ # FastAPI backend server │ ├── blockchain/ # Gas oracle, whale alerts -│ ├── broker/ # INDstocks REST/WebSocket + Binance + paper engine +│ ├── broker/ # INDstocks + Kite Connect + Binance + paper engine + market data provider │ ├── cli/ # Typer CLI (analyze, trade, scan, daemon, monitor) │ ├── db/ # Supabase client + repositories │ ├── execution/ # Executor, safety checker, order router, daemon, monitor @@ -437,7 +461,7 @@ skopaqtrader/ ## Security - **No secrets in the repository.** All API keys, tokens, and credentials are loaded from environment variables via `.env` (gitignored). See [`.env.example`](.env.example) for the full list of configurable keys. -- **INDstocks tokens** are stored locally in `~/.skopaq/token.json` (gitignored) and validated on every daemon session start. +- **Broker tokens** are encrypted at rest in `~/.skopaq/` (INDstocks: `token.enc`, Kite: `kite_token.enc`) using Fernet symmetric encryption and validated on every daemon session start. - **Immutable safety rules** in `skopaq/constants.py` enforce position limits, order value caps, and rate limits that cannot be overridden at runtime. - **Daemon safety variants** apply tighter limits for unattended operation (fewer positions, lower order caps, slower pace). - **Live trading double-gate** — the `trade` and `daemon` CLI commands require an explicit confirmation prompt before executing real orders. @@ -533,6 +557,7 @@ All product names, logos, and brands mentioned in this project are property of t - **NSE** is a trademark of National Stock Exchange of India Limited. - **BSE** is a trademark of BSE Limited. - **INDstocks** is a trademark of its respective owner. +- **Zerodha** and **Kite Connect** are trademarks of Zerodha Broking Ltd. - **Supabase**, **Vercel**, **Railway**, **Upstash**, **Cloudflare**, and **OpenRouter** are trademarks of their respective companies. All trademarks are used here solely for identification and interoperability purposes. diff --git a/docs/kite-connect-setup.md b/docs/kite-connect-setup.md new file mode 100644 index 0000000..135899c --- /dev/null +++ b/docs/kite-connect-setup.md @@ -0,0 +1,451 @@ +# Kite Connect (Zerodha) Setup Guide + +This guide walks you through setting up Kite Connect as your broker in SkopaqTrader — from creating a Zerodha developer app to placing your first paper trade. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Step 1: Create a Kite Connect App](#step-1-create-a-kite-connect-app) +3. [Step 2: Configure SkopaqTrader](#step-2-configure-skopaqtrader) +4. [Step 3: Authenticate (Get Access Token)](#step-3-authenticate-get-access-token) +5. [Step 4: Verify the Setup](#step-4-verify-the-setup) +6. [Step 5: Paper Trading](#step-5-paper-trading) +7. [Step 6: Live Trading](#step-6-live-trading) +8. [Daily Login Flow](#daily-login-flow) +9. [Token Lifecycle](#token-lifecycle) +10. [Troubleshooting](#troubleshooting) +11. [API Reference (Quick)](#api-reference-quick) + +--- + +## Prerequisites + +- A **Zerodha trading account** (demat + trading) +- A **Kite Connect developer subscription** (one-time fee of Rs. 2,000/month from Zerodha) +- Python 3.11+ with SkopaqTrader installed + +> **Note:** Kite Connect is a paid API product from Zerodha. You need an active subscription at [developers.kite.trade](https://developers.kite.trade). This is separate from your regular Zerodha trading account. + +--- + +## Step 1: Create a Kite Connect App + +1. Go to [developers.kite.trade](https://developers.kite.trade) and log in with your Zerodha credentials. + +2. Click **Create new app** and fill in: + + | Field | Value | + |-------|-------| + | **App name** | `SkopaqTrader` (or any name) | + | **Type** | `Connect` | + | **Redirect URL** | `http://127.0.0.1:5000/callback` | + | **Description** | Algorithmic trading platform | + + > The redirect URL is where Zerodha sends you after login. For local development, `http://127.0.0.1:5000/callback` works. You'll extract the `request_token` from this URL manually. + +3. After creation, note down: + - **API Key** (e.g., `xk8v2abc3def4g`) + - **API Secret** (e.g., `h7j9k2m4n6p8q0...` — a long alphanumeric string) + + These are on your app's detail page at developers.kite.trade. + +--- + +## Step 2: Configure SkopaqTrader + +Add these to your `.env` file at the project root: + +```bash +# ===== Broker Selection ===== +SKOPAQ_BROKER=kite + +# ===== Kite Connect Credentials ===== +SKOPAQ_KITE_API_KEY=your_api_key_here +SKOPAQ_KITE_API_SECRET=your_api_secret_here +``` + +That's it for config. The access token is generated via the login flow below. + +--- + +## Step 3: Authenticate (Get Access Token) + +Kite Connect uses OAuth2. You must log in via browser once per trading day (tokens expire at ~6:00 AM IST next day). + +### Option A: CLI Flow (Recommended) + +```bash +# 1. Get the login URL +skopaq kite login-url +# Output: Open this URL in your browser: +# https://kite.zerodha.com/connect/login?v=3&api_key=xk8v2abc3def4g + +# 2. Open the URL in your browser +# - Log in with your Zerodha credentials +# - Complete 2FA (TOTP/PIN) +# - You'll be redirected to your redirect URL with a request_token: +# http://127.0.0.1:5000/callback?request_token=abc123xyz&status=success + +# 3. Copy the request_token value and run: +skopaq kite session abc123xyz +# Output: Kite session established. Token: a1b2c3d4... + +# 4. Verify +skopaq kite status +# Output: Kite token valid. Expires in 18:30:00 (at 2026-03-29 06:00:00+05:30) +``` + +### Option B: Direct Token (Advanced) + +If you obtain an access token through your own OAuth flow or automation: + +```bash +skopaq kite set-token your_access_token_here +``` + +### Option C: Environment Variable + +Set the token directly in `.env` (useful for CI/cron): + +```bash +SKOPAQ_KITE_ACCESS_TOKEN=your_access_token_here +``` + +> **Warning:** Access tokens expire daily at ~6:00 AM IST. You must re-authenticate each trading day. + +--- + +## Step 4: Verify the Setup + +```bash +# Check overall system status (shows broker, token health, LLMs) +skopaq status + +# Expected output includes: +# Broker: kite +# Token: Valid (expires in 18:30:00) +``` + +--- + +## Step 5: Paper Trading + +Paper trading works **with or without a Kite token**. When a token is available, paper mode uses real Kite market data. Without a token, it falls back to yfinance (free, no credentials). + +```bash +# Analyze a stock (no execution) +skopaq analyze RELIANCE + +# Paper trade a specific symbol (full lifecycle: trade → monitor → close) +skopaq trade RELIANCE + +# Auto-discover and paper trade (scan → trade → monitor → close) +skopaq trade # Best single pick from NIFTY 50 +skopaq trade --top 3 # Trade top 3 scanner picks +skopaq trade --watchlist "RELIANCE,TCS,INFY" # Scan specific symbols + +# Run the scanner in paper mode (no execution) +skopaq scan + +# Full autonomous paper session (daemon lifecycle) +skopaq daemon --once --paper +``` + +### How Paper Mode Gets Market Data + +``` +Priority 1: Kite Connect API (if token available — best data, real bid/ask) + | + v (falls back if no token) +Priority 2: yfinance (free, no credentials — delayed data) + | + v (falls back if yfinance fails) +Priority 3: Cached quotes (last known price) +``` + +The `MarketDataProvider` handles this automatically. You never need to configure the fallback — it just works. + +--- + +## Step 6: Live Trading + +> **WARNING:** Live trading uses real money. Start with paper mode until you are confident in the system. You are solely responsible for all trades. + +```bash +# Set live mode in .env +SKOPAQ_TRADING_MODE=live +SKOPAQ_BROKER=kite + +# Or pass --live flag (requires confirmation) +skopaq trade RELIANCE --live + +# Autonomous live session (requires --confirm-live for cron) +skopaq daemon --once --live --confirm-live +``` + +### Pre-Live Checklist + +- [ ] Paper traded successfully for at least 1 week +- [ ] Kite token is valid (`skopaq kite status`) +- [ ] Safety rules reviewed in `skopaq/constants.py` +- [ ] Position limits appropriate for your capital +- [ ] Stop-loss rules enabled (`require_stop_loss: true`) + +--- + +## Daily Login Flow + +Kite tokens expire at ~6:00 AM IST every day. For daily trading: + +### Manual (Interactive) + +```bash +# Each morning before market open (9:15 AM IST): +skopaq kite login-url # Open URL in browser +# Complete login + 2FA +skopaq kite session # Exchange request_token +skopaq kite status # Verify +``` + +### Automated (Advanced) + +For unattended daemon operation, you need to automate the login flow. Common approaches: + +1. **Selenium/Playwright script** — Automate the browser login + 2FA +2. **TOTP automation** — Generate TOTP codes programmatically using your Zerodha authenticator secret +3. **Kite Publisher** — Zerodha's postback service can be configured to auto-generate tokens + +Example automation skeleton (not included — you must build this for your setup): + +```python +# pseudocode for automated login +import pyotp + +totp = pyotp.TOTP("your_totp_secret") +# 1. POST to kite login with user_id + password +# 2. POST TOTP code +# 3. Extract request_token from redirect +# 4. Call: skopaq kite session +``` + +> **Security:** Never store your Zerodha password or TOTP secret in the codebase. Use a secrets manager or environment variables. + +--- + +## Token Lifecycle + +``` +Browser Login → request_token (single-use, expires in minutes) + | + v +POST /session/token → access_token (valid until ~6 AM IST next day) + | + v +Stored encrypted in ~/.skopaq/kite_token.enc + | + v +Used for all API calls: Authorization: token {api_key}:{access_token} +``` + +| Token | Lifetime | How to Get | +|-------|----------|------------| +| `request_token` | ~5 minutes | Browser login redirect URL | +| `access_token` | Until ~6:00 AM IST | `skopaq kite session ` | + +### Token Storage + +- Encrypted at rest using Fernet (AES-128-CBC) +- Stored in `~/.skopaq/kite_token.enc` +- Encryption key in `~/.skopaq/kite_token.key` (chmod 600) +- Expiry warnings at 2h, 1h, 30min, 10min before expiry + +--- + +## Troubleshooting + +### "No Kite token stored" + +```bash +skopaq kite login-url # Get login URL +# Complete browser login +skopaq kite session +``` + +### "Kite token EXPIRED" + +Tokens expire daily at ~6 AM IST. Re-authenticate: + +```bash +skopaq kite login-url +# ... login flow ... +skopaq kite session +``` + +### "Session generation failed (403)" + +- **Invalid request_token** — Request tokens are single-use and expire in minutes. Get a fresh one. +- **Wrong API secret** — Check `SKOPAQ_KITE_API_SECRET` in `.env`. +- **App not active** — Check your app status at developers.kite.trade. + +### "API error 403: Forbidden" + +- Token has expired (check `skopaq kite status`) +- IP not whitelisted (if your Kite app has IP restrictions) +- API subscription expired at developers.kite.trade + +### "No quote available for SYMBOL" + +In paper mode without a Kite token, the system falls back to yfinance. If yfinance also fails: + +- Check internet connectivity +- The symbol might not exist on NSE (check spelling) +- yfinance may be rate-limited (wait and retry) + +### "Cannot resolve instrument token" + +The symbol doesn't exist in Kite's instrument master: + +- Verify the symbol is listed on NSE: search on [kite.zerodha.com](https://kite.zerodha.com) +- Use the exact `tradingsymbol` (e.g., `RELIANCE`, not `RELIANCE.NS`) + +### Paper trading works but live fails + +- Verify `SKOPAQ_TRADING_MODE=live` in `.env` +- Verify Kite token is valid: `skopaq kite status` +- Check funds: log in to [kite.zerodha.com](https://kite.zerodha.com) and verify available margin +- Check if market is open (NSE: 9:15 AM - 3:30 PM IST, Mon-Fri) + +--- + +## API Reference (Quick) + +These are the Kite Connect REST API endpoints used by SkopaqTrader. Full documentation: [kite.trade/docs/connect](https://kite.trade/docs/connect/v3/). + +### Authentication + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/session/token` | POST | Exchange `request_token` for `access_token` | + +**Auth header for all other calls:** +``` +Authorization: token {api_key}:{access_token} +X-Kite-Version: 3 +``` + +### Market Data + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/quote?i=NSE:RELIANCE` | GET | Full quote (LTP, OHLC, depth, volume) | +| `/quote/ltp?i=NSE:RELIANCE` | GET | Last traded price only | +| `/instruments/NSE` | GET | Full instrument master (CSV, ~3MB) | +| `/instruments/historical/{token}/{interval}` | GET | OHLCV candles | + +**Interval values:** `minute`, `5minute`, `15minute`, `30minute`, `60minute`, `day`, `week`, `month` + +### Orders + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/orders/{variety}` | POST | Place order (variety: `regular`, `amo`, `co`, `iceberg`) | +| `/orders/{variety}/{order_id}` | PUT | Modify pending order | +| `/orders/{variety}/{order_id}` | DELETE | Cancel pending order | +| `/orders` | GET | All orders for the day | +| `/orders/{order_id}/trades` | GET | Trades for an order | +| `/trades` | GET | All trades for the day | + +### Portfolio + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/portfolio/positions` | GET | Open positions (`net` + `day`) | +| `/portfolio/holdings` | GET | Delivery holdings | +| `/user/margins` | GET | Available funds per segment | +| `/user/profile` | GET | User profile | + +### Rate Limits + +- **General API:** 10 requests/second +- **Order API:** 10 requests/second (shared with general) +- **Historical data:** 3 requests/second + +SkopaqTrader handles rate limiting automatically via token bucket (`skopaq/broker/rate_limiter.py`). + +--- + +## Architecture: How Kite Integrates + +``` +┌──────────────────────────────────────────────────────────┐ +│ SkopaqTrader │ +│ │ +│ .env: SKOPAQ_BROKER=kite │ +│ │ │ +│ v │ +│ ┌─────────────┐ ┌──────────────────┐ │ +│ │ SkopaqConfig │───>│ _create_live_ │ │ +│ │ broker="kite"│ │ client(config) │ │ +│ └─────────────┘ └────────┬─────────┘ │ +│ │ │ +│ v │ +│ ┌───────────────────┐ │ +│ │ KiteConnectClient │ │ +│ │ (kite_client.py) │ │ +│ └────────┬──────────┘ │ +│ │ │ +│ ┌───────────────────┼────────────────┐ │ +│ │ │ │ │ +│ v v v │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ OrderRouter │ │ MarketData │ │ Position │ │ +│ │ (live/paper) │ │ Provider │ │ Monitor │ │ +│ └──────┬───┬──┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +│ live │ │ paper │ get_quote() │ get_ltp() │ +│ v v v v │ +│ ┌──────┐ ┌──────────┐ ┌─────────────────────┐ │ +│ │ Kite │ │ Paper │ │ Kite API / yfinance │ │ +│ │ API │ │ Engine │ │ (auto-fallback) │ │ +│ └──────┘ └──────────┘ └─────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `skopaq/broker/kite_client.py` | Async REST client for Kite Connect API v3 | +| `skopaq/broker/kite_token_manager.py` | OAuth2 token lifecycle (login, store, expire) | +| `skopaq/broker/market_data.py` | Multi-source market data (Kite/yfinance/cache) | +| `skopaq/config.py` | `SKOPAQ_BROKER`, `SKOPAQ_KITE_API_KEY`, etc. | +| `skopaq/cli/main.py` | `skopaq kite` CLI commands | + +### Differences from INDstocks + +| Aspect | INDstocks | Kite Connect | +|--------|-----------|--------------| +| Auth header | `Authorization: TOKEN` | `Authorization: token api_key:access_token` | +| Symbol format | `scrip-codes=NSE_2885` | `i=NSE:RELIANCE` | +| Token lifetime | 24h rolling | Until ~6 AM IST | +| Token source | Dashboard copy-paste | OAuth2 browser login | +| Orders | `POST /order` + `algo_id` | `POST /orders/{variety}` | +| Product types | `INTRADAY`, `MARGIN`, `CNC` | `MIS`, `NRML`, `CNC` | +| Positions | Flat list | `{"net": [...], "day": [...]}` | +| Historical | Epoch ms input / epoch s output | Datetime strings / ISO timestamps | +| Brokerage | Varies | Rs. 20/order or 0.03% (whichever lower) | + +--- + +## Cost Summary + +| Item | Cost | +|------|------| +| Zerodha trading account | Free (no AMC for demat) | +| Kite Connect API subscription | Rs. 2,000/month | +| Historical data add-on | Rs. 2,000/month (optional, for backtesting) | +| Per-trade brokerage | Rs. 20/executed order or 0.03% | +| SkopaqTrader | Free (open source) | + +> Check [zerodha.com/pricing](https://zerodha.com/pricing) and [developers.kite.trade](https://developers.kite.trade) for current pricing. diff --git a/docs/market-data-providers.md b/docs/market-data-providers.md new file mode 100644 index 0000000..2d7c16d --- /dev/null +++ b/docs/market-data-providers.md @@ -0,0 +1,146 @@ +# Market Data Providers + +SkopaqTrader's `MarketDataProvider` fetches real-time market data from multiple sources with automatic fallback. This means paper trading works out of the box — no broker token required. + +## Fallback Chain + +Sources are tried in order. The first one that returns valid data wins: + +``` +1. Broker API (Kite Connect / INDstocks) ← best data, requires trading account +2. Angel One SmartAPI ← free, real-time, real bid/ask +3. Upstox API ← free, real-time, backup +4. yfinance ← free, no setup, delayed data +5. Binance public API ← crypto only +6. Stale cache ← last known quote +``` + +## Data Quality Comparison + +| Source | Real-time | Bid/Ask | Depth | Cost | Auth Required | +|--------|-----------|---------|-------|------|---------------| +| **Kite Connect** | Yes | Real | 5-level | Rs. 2,000/mo | Zerodha account + API subscription | +| **INDstocks** | Yes | Real | Limited | Paid | INDstocks account | +| **Angel One SmartAPI** | Yes | Real | 5-level | **Free** | Angel One demat (free to open) | +| **Upstox API** | Yes | Real | 5-level | **Free** | Upstox demat (free to open) | +| **yfinance** | No (15-20 min delay) | **Synthetic** (estimated ± 0.05%) | None | Free | None | +| **Binance** | Yes | Real | Full | Free | None (public endpoints) | + +## Setup Guides + +### Zero Config (yfinance only) + +No setup needed. Just run: + +```bash +skopaq trade RELIANCE +``` + +yfinance provides delayed data with synthetic bid/ask. Good enough for testing logic, but fills won't be realistic. + +### Angel One SmartAPI (Recommended Free Option) + +Best free option — real-time data with actual bid/ask from NSE. + +1. **Open a free Angel One demat account** at [angelone.com](https://www.angelone.in/) +2. **Generate API key** at [smartapi.angelbroking.com](https://smartapi.angelbroking.com/) + - Log in → My Apps → Create App + - Note down: API Key +3. **Add credentials to `.env`:** + +```bash +SKOPAQ_ANGELONE_API_KEY=your_api_key +SKOPAQ_ANGELONE_CLIENT_ID=your_client_id # Angel One client code (e.g., A12345) +SKOPAQ_ANGELONE_PASSWORD=your_password +SKOPAQ_ANGELONE_TOTP_SECRET=your_totp_secret # Optional: for automated TOTP +``` + +4. **That's it.** `MarketDataProvider` auto-detects the credentials and connects on first use. + +> **Note:** Angel One login requires TOTP (2FA). If you set `SKOPAQ_ANGELONE_TOTP_SECRET`, the client generates TOTP codes automatically (requires `pyotp` package: `pip install pyotp`). Without it, you'll need to handle TOTP externally. + +### Upstox API (Backup Free Option) + +1. **Open a free Upstox demat account** at [upstox.com](https://upstox.com/) +2. **Create an API app** at [upstox.com/developer/apps](https://upstox.com/developer/apps) + - Note down: API Key, API Secret + - Set redirect URL: `http://127.0.0.1:5000/callback` +3. **Complete OAuth2 login** to get an access token: + +```bash +# Open this URL in browser: +https://api.upstox.com/v2/login/authorization/dialog?client_id=YOUR_API_KEY&redirect_uri=http://127.0.0.1:5000/callback&response_type=code + +# After login, you'll be redirected with ?code=xxx +# Exchange the code for an access_token via: +curl -X POST https://api.upstox.com/v2/login/authorization/token \ + -d "code=YOUR_CODE&client_id=YOUR_API_KEY&client_secret=YOUR_SECRET&redirect_uri=http://127.0.0.1:5000/callback&grant_type=authorization_code" +``` + +4. **Add to `.env`:** + +```bash +SKOPAQ_UPSTOX_ACCESS_TOKEN=your_access_token +``` + +> **Note:** Upstox access tokens expire daily. Re-authenticate each morning before market open. + +### Using Multiple Providers + +You can configure all providers simultaneously. `MarketDataProvider` uses the first one that works: + +```bash +# .env — configure all providers (best to worst) +SKOPAQ_BROKER=kite # Primary: Kite Connect +SKOPAQ_KITE_API_KEY=... + +SKOPAQ_ANGELONE_API_KEY=... # Fallback 1: Angel One (free) +SKOPAQ_ANGELONE_CLIENT_ID=... +SKOPAQ_ANGELONE_PASSWORD=... + +SKOPAQ_UPSTOX_ACCESS_TOKEN=... # Fallback 2: Upstox (free) + +# yfinance always available as last resort (no config needed) +``` + +If Kite token expires mid-session, the system automatically falls back to Angel One, then Upstox, then yfinance — no interruption. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ MarketDataProvider │ +│ │ +│ get_quote("RELIANCE") │ +│ │ │ +│ ├─ 1. _fetch_broker_quote() → Kite/IND │ +│ │ (if broker client attached) │ +│ │ │ +│ ├─ 2. _fetch_angelone_quote() → Angel One │ +│ │ (auto-init from config) │ +│ │ │ +│ ├─ 3. _fetch_upstox_quote() → Upstox │ +│ │ (auto-init from config) │ +│ │ │ +│ ├─ 4. _fetch_yfinance_quote() → Yahoo │ +│ │ (always available) │ +│ │ │ +│ ├─ 5. _fetch_binance_quote() → Binance │ +│ │ (crypto only) │ +│ │ │ +│ └─ 6. stale cache → last known │ +│ │ +│ Result: Quote(symbol, ltp, bid, ask, ohlcv, ...) │ +└─────────────────────────────────────────────────────┘ +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `skopaq/broker/market_data.py` | MarketDataProvider with fallback chain | +| `skopaq/broker/angelone_client.py` | Angel One SmartAPI client | +| `skopaq/broker/upstox_client.py` | Upstox API v2 client | +| `skopaq/broker/kite_client.py` | Kite Connect client | +| `skopaq/broker/client.py` | INDstocks client | +| `skopaq/config.py` | All provider credentials | diff --git a/skopaq/api/server.py b/skopaq/api/server.py index 46bb865..451c503 100644 --- a/skopaq/api/server.py +++ b/skopaq/api/server.py @@ -13,38 +13,51 @@ from fastapi.middleware.cors import CORSMiddleware from skopaq import __version__ -from skopaq.broker.token_manager import TokenManager from skopaq.config import SkopaqConfig logger = logging.getLogger(__name__) + app = FastAPI( title="SkopaqTrader API", + description="AI algorithmic trading platform for Indian equities.", version=__version__, - docs_url="/docs", ) -# CORS — allow frontend (Vercel) to call the API app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Tighten in production + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +def _get_token_health(config: SkopaqConfig): + """Get token health for the configured broker.""" + if config.broker == "kite": + from skopaq.broker.kite_token_manager import KiteTokenManager + return KiteTokenManager().get_health() + else: + from skopaq.broker.token_manager import TokenManager + return TokenManager().get_health() + + +# ── Health Check ───────────────────────────────────────────────────────────── + + @app.get("/health") async def health() -> dict: - """Health check endpoint (used by Railway).""" + """Lightweight health check for Railway / load balancers.""" config = SkopaqConfig() - token_mgr = TokenManager() - health = token_mgr.get_health() + health = _get_token_health(config) return { "status": "ok", "version": __version__, "mode": config.trading_mode, + "broker": config.broker, + "product": config.default_product, "token_valid": health.valid, "timestamp": datetime.now(timezone.utc).isoformat(), } @@ -54,20 +67,28 @@ async def health() -> dict: async def system_status() -> dict: """Detailed system status for the dashboard.""" config = SkopaqConfig() - token_mgr = TokenManager() - token_health = token_mgr.get_health() + token_health = _get_token_health(config) return { "version": __version__, "mode": config.trading_mode, + "product": config.default_product, "broker": { - "name": "INDstocks", - "base_url": config.indstocks_base_url, + "name": config.broker, "token_valid": token_health.valid, - "token_expires_at": token_health.expires_at.isoformat() if token_health.expires_at else None, - "token_remaining": str(token_health.remaining) if token_health.remaining else None, + "token_expires_at": ( + token_health.expires_at.isoformat() if token_health.expires_at else None + ), + "token_remaining": ( + str(token_health.remaining) if token_health.remaining else None + ), "token_warning": token_health.warning or None, }, + "market_data": { + "angelone": bool(config.angelone_api_key.get_secret_value()), + "upstox": bool(config.upstox_access_token.get_secret_value()), + "yfinance": True, # Always available + }, "services": { "supabase": bool(config.supabase_url), "redis": bool(config.upstash_redis_url), @@ -87,69 +108,36 @@ async def system_status() -> dict: _scanner_engine = None -def _get_scanner(): - """Lazy-init scanner engine singleton.""" - global _scanner_engine - if _scanner_engine is None: - from skopaq.scanner import ScannerEngine, Watchlist - config = SkopaqConfig() - _scanner_engine = ScannerEngine( - watchlist=Watchlist(), - cycle_seconds=config.scanner_cycle_seconds, - max_candidates=config.scanner_max_candidates, - ) - return _scanner_engine +@app.post("/api/scan") +async def run_scan(max_candidates: int = 5) -> dict: + """Run a single scanner cycle and return candidates.""" + try: + from skopaq.cli.main import _run_scan + candidates = await _run_scan(max_candidates) + return { + "candidates": [c.to_dict() for c in candidates] if candidates else [], + } + except Exception as exc: + logger.error("Scanner failed: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail=str(exc)) from exc -@app.on_event("startup") -async def _start_scanner(): - """Start scanner as a background task if enabled in config.""" - config = SkopaqConfig() - if config.scanner_enabled: - scanner = _get_scanner() - await scanner.start() - logger.info("Scanner background task started") - - -@app.on_event("shutdown") -async def _stop_scanner(): - """Stop scanner on shutdown.""" - if _scanner_engine and _scanner_engine.running: - await _scanner_engine.stop() - - -@app.get("/api/scanner/status") -async def scanner_status() -> dict: - """Scanner engine status.""" - scanner = _get_scanner() - return scanner.status - - -@app.get("/api/scanner/candidates") -async def scanner_candidates() -> dict: - """Recent scanner candidates.""" - scanner = _get_scanner() - candidates = [] - # Drain the queue (non-blocking) - while not scanner.candidate_queue.empty(): - try: - c = scanner.candidate_queue.get_nowait() - candidates.append(c.to_dict()) - except Exception: - break - return { - "candidates": candidates, - "last_candidates": [c.to_dict() for c in scanner._last_candidates], - } + +# ── Portfolio ───────────────────────────────────────────────────────────────── @app.get("/api/portfolio") async def portfolio() -> dict: """Current portfolio snapshot (paper mode).""" + from skopaq.broker.market_data import MarketDataProvider from skopaq.broker.paper_engine import PaperEngine config = SkopaqConfig() - paper = PaperEngine(initial_capital=config.initial_paper_capital) + market_data = MarketDataProvider(config) + paper = PaperEngine( + initial_capital=config.initial_paper_capital, + market_data=market_data, + ) snapshot = paper.get_snapshot() return { @@ -160,5 +148,6 @@ async def portfolio() -> dict: "positions": [p.model_dump() for p in snapshot.positions], "open_orders": snapshot.open_orders, "mode": config.trading_mode, + "product": config.default_product, "timestamp": snapshot.timestamp.isoformat(), } diff --git a/skopaq/broker/__init__.py b/skopaq/broker/__init__.py index 5cfd3d8..58b0fb8 100644 --- a/skopaq/broker/__init__.py +++ b/skopaq/broker/__init__.py @@ -1 +1 @@ -"""INDstocks broker integration — REST client, WebSocket, token management, paper trading.""" +"""INDstocks & Kite Connect broker integration — REST clients, WebSocket, token management, paper trading.""" diff --git a/skopaq/broker/angelone_client.py b/skopaq/broker/angelone_client.py new file mode 100644 index 0000000..1143c5d --- /dev/null +++ b/skopaq/broker/angelone_client.py @@ -0,0 +1,381 @@ +"""Angel One SmartAPI client for free real-time Indian market data. + +Angel One provides free API access to all customers for NSE/BSE market data +including real-time quotes with actual bid/ask, 5-level market depth, +and historical OHLCV data. + +Authentication: API key + client code + password + TOTP → JWT token. +Base URL: https://apiconnect.angelone.in + +Usage:: + + async with AngelOneClient(config) as client: + quote = await client.get_quote("RELIANCE", "NSE") + ltp = await client.get_ltp("RELIANCE", "NSE") + +Setup: + 1. Open free Angel One demat account at angelone.com + 2. Generate API key at smartapi.angelbroking.com + 3. Set SKOPAQ_ANGELONE_API_KEY, SKOPAQ_ANGELONE_CLIENT_ID, + SKOPAQ_ANGELONE_PASSWORD, SKOPAQ_ANGELONE_TOTP_SECRET in .env +""" + +from __future__ import annotations + +import hashlib +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx + +from skopaq.broker.models import HistoricalCandle, Quote +from skopaq.broker.rate_limiter import RateLimiter + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://apiconnect.angelone.in" +_api_limiter = RateLimiter(max_calls=10, period=1.0) + +# Angel One exchange segment mapping +_EXCHANGE_MAP = { + "NSE": "NSE", + "BSE": "BSE", + "NFO": "NFO", + "MCX": "MCX", +} + + +class AngelOneError(Exception): + def __init__(self, message: str, status_code: int = 0) -> None: + super().__init__(message) + self.status_code = status_code + + +class AngelOneClient: + """Async REST client for Angel One SmartAPI. + + Provides free real-time market data (quotes, LTP, depth) and + historical OHLCV candles for NSE/BSE equities and F&O. + + Usage:: + + async with AngelOneClient(config) as client: + quote = await client.get_quote("RELIANCE", "NSE") + """ + + def __init__( + self, + api_key: str, + client_id: str, + password: str, + totp_secret: str = "", + timeout: float = 30.0, + ) -> None: + self._api_key = api_key + self._client_id = client_id + self._password = password + self._totp_secret = totp_secret + self._timeout = timeout + self._client: Optional[httpx.AsyncClient] = None + self._jwt_token: str = "" + self._refresh_token: str = "" + + async def __aenter__(self) -> AngelOneClient: + self._client = httpx.AsyncClient( + base_url=_BASE_URL, + timeout=self._timeout, + limits=httpx.Limits(max_connections=10, max_keepalive_connections=5), + ) + await self._login() + return self + + async def __aexit__(self, *exc: object) -> None: + if self._client: + # Attempt logout + try: + await self._request("POST", "/rest/secure/angelbroking/user/v1/logout", { + "clientcode": self._client_id, + }) + except Exception: + pass + await self._client.aclose() + self._client = None + + # ── Auth ───────────────────────────────────────────────────────────── + + async def _login(self) -> None: + """Authenticate and obtain JWT token. + + Endpoint: POST /rest/secure/angelbroking/user/v1/loginByPassword + """ + totp = self._generate_totp() if self._totp_secret else "" + + body = { + "clientcode": self._client_id, + "password": self._password, + } + if totp: + body["totp"] = totp + + data = await self._request_public( + "POST", + "/rest/secure/angelbroking/user/v1/loginByPassword", + body, + ) + + self._jwt_token = data.get("jwtToken", "") + self._refresh_token = data.get("refreshToken", "") + + if not self._jwt_token: + raise AngelOneError("Login failed: no JWT token returned") + + logger.info("Angel One login successful for client %s", self._client_id) + + def _generate_totp(self) -> str: + """Generate TOTP code from secret.""" + try: + import pyotp + return pyotp.TOTP(self._totp_secret).now() + except ImportError: + logger.warning("pyotp not installed — TOTP generation skipped") + return "" + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._jwt_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "X-UserType": "USER", + "X-SourceID": "WEB", + "X-ClientLocalIP": "127.0.0.1", + "X-ClientPublicIP": "127.0.0.1", + "X-MACAddress": "00:00:00:00:00:00", + "X-PrivateKey": self._api_key, + } + + # ── Request helpers ────────────────────────────────────────────────── + + async def _request_public( + self, method: str, path: str, body: dict, + ) -> dict: + """Send unauthenticated request (login only).""" + if self._client is None: + raise AngelOneError("Client not initialised") + + await _api_limiter.acquire() + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "X-UserType": "USER", + "X-SourceID": "WEB", + "X-ClientLocalIP": "127.0.0.1", + "X-ClientPublicIP": "127.0.0.1", + "X-MACAddress": "00:00:00:00:00:00", + "X-PrivateKey": self._api_key, + } + try: + resp = await self._client.request( + method, path, headers=headers, json=body, + ) + except httpx.HTTPError as exc: + raise AngelOneError(f"HTTP error: {exc}") from exc + + if resp.status_code >= 400: + raise AngelOneError( + f"API error {resp.status_code}: {resp.text}", + status_code=resp.status_code, + ) + + result = resp.json() + if not result.get("status"): + raise AngelOneError(result.get("message", "Request failed")) + + return result.get("data", {}) + + async def _request( + self, method: str, path: str, body: Optional[dict] = None, + ) -> Any: + """Send authenticated request.""" + if self._client is None: + raise AngelOneError("Client not initialised") + + await _api_limiter.acquire() + try: + resp = await self._client.request( + method, path, + headers=self._headers(), + json=body or {}, + ) + except httpx.HTTPError as exc: + raise AngelOneError(f"HTTP error: {exc}") from exc + + if resp.status_code >= 400: + raise AngelOneError( + f"API error {resp.status_code}: {resp.text}", + status_code=resp.status_code, + ) + + result = resp.json() + if not result.get("status"): + raise AngelOneError(result.get("message", "Request failed")) + + return result.get("data", {}) + + # ── Market Data ────────────────────────────────────────────────────── + + async def get_quote(self, symbol: str, exchange: str = "NSE") -> Quote: + """Fetch full quote with real bid/ask and market depth. + + Endpoint: POST /rest/secure/angelbroking/market/v1/quote/ + """ + # Angel One uses symboltoken (numeric) for API calls. + # For simplicity, we use the search endpoint to resolve first. + token = await self._resolve_symbol_token(symbol, exchange) + + data = await self._request( + "POST", + "/rest/secure/angelbroking/market/v1/quote/", + { + "mode": "FULL", + "exchangeTokens": { + exchange: [token], + }, + }, + ) + + # Response: {"fetched": [{"exchange": "NSE", "tradingSymbol": "RELIANCE", ...}]} + fetched = data.get("fetched", []) + if not fetched: + return Quote(symbol=symbol, exchange=exchange) + + q = fetched[0] + return Quote( + symbol=symbol, + exchange=exchange, + ltp=float(q.get("ltp", 0)), + open=float(q.get("open", 0)), + high=float(q.get("high", 0)), + low=float(q.get("low", 0)), + close=float(q.get("close", 0)), + volume=int(q.get("tradeVolume", 0)), + change=float(q.get("netChange", 0)), + change_pct=float(q.get("percentChange", 0)), + bid=float(q.get("depth", {}).get("buy", [{}])[0].get("price", 0)), + ask=float(q.get("depth", {}).get("sell", [{}])[0].get("price", 0)), + timestamp=datetime.now(timezone.utc), + ) + + async def get_ltp(self, symbol: str, exchange: str = "NSE") -> float: + """Fetch just the last traded price. + + Endpoint: POST /rest/secure/angelbroking/market/v1/quote/ + Uses LTP mode for minimal data transfer. + """ + token = await self._resolve_symbol_token(symbol, exchange) + + data = await self._request( + "POST", + "/rest/secure/angelbroking/market/v1/quote/", + { + "mode": "LTP", + "exchangeTokens": { + exchange: [token], + }, + }, + ) + + fetched = data.get("fetched", []) + if fetched: + return float(fetched[0].get("ltp", 0)) + return 0.0 + + async def get_historical( + self, + symbol: str, + exchange: str = "NSE", + interval: str = "ONE_DAY", + from_date: str = "", + to_date: str = "", + ) -> list[HistoricalCandle]: + """Fetch historical OHLCV candles. + + Endpoint: POST /rest/secure/angelbroking/historical/v1/getCandleData + + Args: + interval: ONE_MINUTE, FIVE_MINUTE, FIFTEEN_MINUTE, THIRTY_MINUTE, + ONE_HOUR, ONE_DAY + """ + token = await self._resolve_symbol_token(symbol, exchange) + + if not from_date: + from datetime import timedelta + from_date = (datetime.now() - timedelta(days=365)).strftime( + "%Y-%m-%d %H:%M" + ) + if not to_date: + to_date = datetime.now().strftime("%Y-%m-%d %H:%M") + + data = await self._request( + "POST", + "/rest/secure/angelbroking/historical/v1/getCandleData", + { + "exchange": exchange, + "symboltoken": token, + "interval": interval, + "fromdate": from_date, + "todate": to_date, + }, + ) + + candles = [] + for row in data if isinstance(data, list) else []: + # [timestamp, open, high, low, close, volume] + if isinstance(row, list) and len(row) >= 6: + try: + dt = datetime.fromisoformat(str(row[0])) + except (ValueError, TypeError): + dt = datetime.now() + candles.append(HistoricalCandle( + timestamp=dt, + open=float(row[1]), + high=float(row[2]), + low=float(row[3]), + close=float(row[4]), + volume=int(row[5]), + )) + return candles + + # ── Symbol resolution ──────────────────────────────────────────────── + + _symbol_cache: dict[str, str] = {} # class-level cache + + async def _resolve_symbol_token(self, symbol: str, exchange: str = "NSE") -> str: + """Resolve a tradingsymbol to Angel One's numeric symboltoken. + + Endpoint: POST /rest/secure/angelbroking/order/v1/searchScrip + """ + cache_key = f"{exchange}:{symbol}" + if cache_key in self._symbol_cache: + return self._symbol_cache[cache_key] + + data = await self._request( + "POST", + "/rest/secure/angelbroking/order/v1/searchScrip", + {"exchange": exchange, "searchscrip": symbol}, + ) + + results = data if isinstance(data, list) else [] + for item in results: + if item.get("tradingsymbol", "").upper() == symbol.upper(): + token = str(item.get("symboltoken", "")) + self._symbol_cache[cache_key] = token + return token + + # Fallback: use first result if exact match not found + if results: + token = str(results[0].get("symboltoken", "")) + self._symbol_cache[cache_key] = token + return token + + raise AngelOneError(f"Cannot resolve symbol token for {symbol} on {exchange}") diff --git a/skopaq/broker/client.py b/skopaq/broker/client.py index aabfa9e..f51bc42 100644 --- a/skopaq/broker/client.py +++ b/skopaq/broker/client.py @@ -481,6 +481,23 @@ async def get_order_book(self) -> list[dict[str, Any]]: return data return [] + async def get_orders(self) -> list[OrderResponse]: + """Fetch today's orders as OrderResponse models. + + Wraps ``get_order_book()`` to provide the same interface as + ``KiteConnectClient.get_orders()`` for broker-agnostic usage. + """ + raw = await self.get_order_book() + return [ + OrderResponse( + order_id=str(o.get("order_id", "")), + status=str(o.get("status", "")), + message=str(o.get("message", "")), + exchange_order_id=o.get("exchange_order_id"), + ) + for o in raw + ] + async def get_order(self, order_id: str, segment: str = "EQUITY") -> dict[str, Any]: """Fetch a single order by ID. diff --git a/skopaq/broker/kite_client.py b/skopaq/broker/kite_client.py new file mode 100644 index 0000000..f05c31a --- /dev/null +++ b/skopaq/broker/kite_client.py @@ -0,0 +1,802 @@ +"""Async REST client for the Kite Connect (Zerodha) broker API. + +Wraps the ``kiteconnect`` Python SDK in an async interface that matches +the same model types used by ``INDstocksClient`` (Quote, Position, Holding, +Funds, OrderRequest, OrderResponse, etc.) so the OrderRouter can dispatch +to either broker transparently. + +Key differences from INDstocks: + - Auth: ``api_key`` + ``access_token`` (OAuth2 flow, valid ~6 AM to 6 AM) + - Orders: ``variety`` param (regular/amo/co/iceberg), ``exchange`` per call + - Instruments: ``instrument_token`` (numeric) + ``tradingsymbol`` + - Historical: datetime objects, intervals like "minute", "day", "5minute" + - Positions: day vs net positions + - Product types: CNC, MIS, NRML (native, no translation needed) + +Usage:: + + async with KiteConnectClient(config, token_mgr) as client: + quote = await client.get_quote("NSE:RELIANCE") +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta +from functools import partial +from typing import Any, Optional + +import httpx + +from skopaq.broker.kite_token_manager import KiteTokenExpiredError, KiteTokenManager +from skopaq.broker.models import ( + CancelOrderRequest, + Funds, + HistoricalCandle, + Holding, + ModifyOrderRequest, + OrderRequest, + OrderResponse, + Position, + Quote, + UserProfile, +) +from skopaq.broker.rate_limiter import RateLimiter +from skopaq.config import SkopaqConfig + +logger = logging.getLogger(__name__) + +# Kite Connect rate limits: 10 orders/sec, 10 requests/sec for historical +_api_limiter = RateLimiter(max_calls=10, period=1.0) +_order_limiter = RateLimiter(max_calls=10, period=1.0) + +# Kite Connect exchange strings +KITE_EXCHANGE_NSE = "NSE" +KITE_EXCHANGE_BSE = "BSE" +KITE_EXCHANGE_NFO = "NFO" +KITE_EXCHANGE_BFO = "BFO" +KITE_EXCHANGE_MCX = "MCX" +KITE_EXCHANGE_CDS = "CDS" + +# Kite Connect product types +KITE_PRODUCT_CNC = "CNC" # Cash and Carry (delivery) +KITE_PRODUCT_MIS = "MIS" # Intraday +KITE_PRODUCT_NRML = "NRML" # Normal (F&O) + +# Kite Connect order types +KITE_ORDER_MARKET = "MARKET" +KITE_ORDER_LIMIT = "LIMIT" +KITE_ORDER_SL = "SL" +KITE_ORDER_SLM = "SL-M" + +# Kite Connect variety types +KITE_VARIETY_REGULAR = "regular" +KITE_VARIETY_AMO = "amo" +KITE_VARIETY_CO = "co" +KITE_VARIETY_ICEBERG = "iceberg" + +# Product mapping from internal to Kite +_PRODUCT_MAP = { + "CNC": KITE_PRODUCT_CNC, + "INTRADAY": KITE_PRODUCT_MIS, + "MARGIN": KITE_PRODUCT_NRML, + "MIS": KITE_PRODUCT_MIS, + "NRML": KITE_PRODUCT_NRML, +} + + +class KiteBrokerError(Exception): + """Raised when a Kite Connect API call fails.""" + + def __init__(self, message: str, status_code: int = 0, body: str = "") -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + + +class KiteConnectClient: + """Async HTTP client for Kite Connect REST API. + + The Kite Connect API is synchronous (REST/JSON), so we wrap all calls + in ``asyncio.to_thread`` for non-blocking execution. This avoids + pulling in the full ``kiteconnect`` SDK as a hard dependency — we use + plain ``httpx`` instead. + + Usage:: + + async with KiteConnectClient(config, token_mgr) as client: + quote = await client.get_quote("NSE:RELIANCE") + """ + + BASE_URL = "https://api.kite.trade" + + def __init__( + self, + config: SkopaqConfig, + token_manager: KiteTokenManager, + *, + timeout: float = 30.0, + ) -> None: + self._api_key = config.kite_api_key.get_secret_value() + self._token_manager = token_manager + self._timeout = timeout + self._client: Optional[httpx.AsyncClient] = None + # Instrument cache: {tradingsymbol: instrument_token} + self._instruments: dict[str, int] = {} + self._instruments_loaded: bool = False + + async def __aenter__(self) -> KiteConnectClient: + self._client = httpx.AsyncClient( + base_url=self.BASE_URL, + timeout=self._timeout, + limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), + ) + return self + + async def __aexit__(self, *exc: object) -> None: + if self._client: + await self._client.aclose() + self._client = None + + # ── Internal helpers ───────────────────────────────────────────────── + + def _headers(self) -> dict[str, str]: + """Build Kite Connect auth headers. + + Kite uses ``Authorization: token api_key:access_token``. + """ + try: + access_token = self._token_manager.get_token() + except KiteTokenExpiredError as exc: + raise KiteBrokerError(str(exc)) from exc + return { + "Authorization": f"token {self._api_key}:{access_token}", + "Content-Type": "application/x-www-form-urlencoded", + "X-Kite-Version": "3", + } + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[dict[str, Any]] = None, + form_data: Optional[dict[str, Any]] = None, + is_order: bool = False, + ) -> Any: + """Send an API request with rate limiting and error handling. + + Kite Connect returns JSON: ``{"status": "success", "data": {...}}`` + or ``{"status": "error", "message": "...", "error_type": "..."}`` + """ + if self._client is None: + raise KiteBrokerError( + "Client not initialised. Use `async with` context manager." + ) + + limiter = _order_limiter if is_order else _api_limiter + await limiter.acquire() + + try: + if form_data is not None: + resp = await self._client.request( + method, + path, + headers=self._headers(), + params=params, + data=form_data, + ) + else: + resp = await self._client.request( + method, + path, + headers=self._headers(), + params=params, + ) + except httpx.HTTPError as exc: + raise KiteBrokerError(f"HTTP error: {exc}") from exc + + if resp.status_code >= 400: + raise KiteBrokerError( + f"API error {resp.status_code}: {resp.text}", + status_code=resp.status_code, + body=resp.text, + ) + + data = resp.json() + + # Kite wraps responses in {"status": "success"|"error", "data": ...} + if isinstance(data, dict): + if data.get("status") == "error": + raise KiteBrokerError( + data.get("message", "Unknown Kite API error"), + body=str(data), + ) + if "data" in data: + return data["data"] + return data + + # ── Market Data ────────────────────────────────────────────────────── + + async def get_quote(self, scrip_code: str, symbol: str = "") -> Quote: + """Fetch full quote for a symbol. + + Args: + scrip_code: Kite instrument key like ``NSE:RELIANCE``. + For compatibility with INDstocksClient signature, also accepts + plain symbols (resolved to ``NSE:{symbol}``). + symbol: Optional human-readable name for the returned Quote. + + Endpoint: ``GET /quote?i=NSE:RELIANCE`` + """ + instrument_key = self._normalize_instrument_key(scrip_code) + data = await self._request( + "GET", "/quote", + params={"i": instrument_key}, + ) + + if isinstance(data, dict): + quote_data = data.get(instrument_key, {}) + return self._parse_quote(quote_data, symbol or scrip_code, instrument_key) + + return Quote(symbol=symbol or scrip_code) + + async def get_quotes( + self, scrip_codes: list[str], symbols: list[str] | None = None, + ) -> list[Quote]: + """Fetch full quotes for multiple symbols. + + Endpoint: ``GET /quote?i=NSE:RELIANCE&i=NSE:TCS`` + """ + instrument_keys = [self._normalize_instrument_key(sc) for sc in scrip_codes] + # Kite accepts multiple `i` params + params_list = [("i", k) for k in instrument_keys] + + # Use raw httpx for multi-value params + if self._client is None: + raise KiteBrokerError("Client not initialised.") + + await _api_limiter.acquire() + try: + resp = await self._client.request( + "GET", "/quote", + headers=self._headers(), + params=params_list, + ) + except httpx.HTTPError as exc: + raise KiteBrokerError(f"HTTP error: {exc}") from exc + + if resp.status_code >= 400: + raise KiteBrokerError( + f"API error {resp.status_code}: {resp.text}", + status_code=resp.status_code, + body=resp.text, + ) + + result = resp.json() + data = result.get("data", result) if isinstance(result, dict) else {} + + quotes: list[Quote] = [] + for i, key in enumerate(instrument_keys): + qd = data.get(key, {}) + sym = symbols[i] if symbols and i < len(symbols) else scrip_codes[i] + quotes.append(self._parse_quote(qd, sym, key)) + return quotes + + async def get_ltp(self, scrip_code: str) -> float: + """Fetch just the last traded price. + + Endpoint: ``GET /quote/ltp?i=NSE:RELIANCE`` + """ + instrument_key = self._normalize_instrument_key(scrip_code) + data = await self._request( + "GET", "/quote/ltp", + params={"i": instrument_key}, + ) + + if isinstance(data, dict): + ltp_data = data.get(instrument_key, {}) + if isinstance(ltp_data, dict): + return float(ltp_data.get("last_price", 0)) + return 0.0 + + async def get_historical( + self, + scrip_code: str, + interval: str = "1day", + start_time: Optional[int] = None, + end_time: Optional[int] = None, + ) -> list[HistoricalCandle]: + """Fetch OHLCV candles. + + Endpoint: ``GET /instruments/historical/{instrument_token}/{interval}`` + + Args: + scrip_code: Kite instrument key (``NSE:RELIANCE``) or instrument token. + interval: Candle interval — maps from INDstocks-style intervals: + ``1day`` → ``day``, ``1minute`` → ``minute``, ``5minute`` → ``5minute`` + start_time: Unix epoch milliseconds (converted to ``YYYY-MM-DD HH:MM:SS``). + end_time: Unix epoch milliseconds (converted to ``YYYY-MM-DD HH:MM:SS``). + """ + # Resolve instrument token + instrument_token = await self._resolve_instrument_token(scrip_code) + kite_interval = self._map_interval(interval) + + # Convert epoch ms to datetime strings + if start_time is not None: + from_dt = datetime.fromtimestamp(start_time / 1000).strftime( + "%Y-%m-%d %H:%M:%S" + ) + else: + from_dt = (datetime.now() - timedelta(days=365)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + if end_time is not None: + to_dt = datetime.fromtimestamp(end_time / 1000).strftime( + "%Y-%m-%d %H:%M:%S" + ) + else: + to_dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + data = await self._request( + "GET", + f"/instruments/historical/{instrument_token}/{kite_interval}", + params={"from": from_dt, "to": to_dt}, + ) + + candles: list[HistoricalCandle] = [] + # Kite returns {"candles": [[ts, o, h, l, c, v], ...]} + raw_candles = data.get("candles", []) if isinstance(data, dict) else [] + for row in raw_candles: + if isinstance(row, list) and len(row) >= 6: + # row[0] is ISO timestamp string like "2024-01-15T09:15:00+0530" + ts = row[0] + if isinstance(ts, str): + dt = datetime.fromisoformat(ts) + else: + dt = datetime.fromtimestamp(int(ts)) + candles.append( + HistoricalCandle( + timestamp=dt, + open=float(row[1]), + high=float(row[2]), + low=float(row[3]), + close=float(row[4]), + volume=int(row[5]), + ) + ) + return candles + + async def get_instruments(self, source: str = "equity") -> str: + """Fetch instruments master as CSV text. + + Endpoint: ``GET /instruments`` or ``GET /instruments/NSE`` + + Returns raw CSV with columns: + instrument_token, exchange_token, tradingsymbol, name, last_price, + expiry, strike, tick_size, lot_size, instrument_type, segment, exchange + """ + exchange = "NSE" if source == "equity" else source.upper() + if self._client is None: + raise KiteBrokerError("Client not initialised.") + + await _api_limiter.acquire() + try: + resp = await self._client.request( + "GET", + f"/instruments/{exchange}", + headers=self._headers(), + ) + except httpx.HTTPError as exc: + raise KiteBrokerError(f"HTTP error: {exc}") from exc + + if resp.status_code >= 400: + raise KiteBrokerError( + f"API error {resp.status_code}: {resp.text}", + status_code=resp.status_code, + body=resp.text, + ) + return resp.text + + # ── Orders ─────────────────────────────────────────────────────────── + + async def place_order(self, order: OrderRequest) -> OrderResponse: + """Place a new order. + + Endpoint: ``POST /orders/{variety}`` + + Translates internal field names to Kite API names: + symbol → tradingsymbol + side → transaction_type + quantity → quantity + price → price + order_type → order_type + product → product + """ + variety = KITE_VARIETY_AMO if order.is_amo else KITE_VARIETY_REGULAR + product = _PRODUCT_MAP.get(order.product.value, KITE_PRODUCT_CNC) + + payload: dict[str, Any] = { + "tradingsymbol": order.symbol, + "exchange": order.exchange.value, + "transaction_type": order.side.value, + "order_type": order.order_type.value, + "quantity": int(order.quantity), + "product": product, + "validity": order.validity.value, + } + if order.price is not None: + payload["price"] = order.price + if order.trigger_price is not None: + payload["trigger_price"] = order.trigger_price + if order.disclosed_quantity > 0: + payload["disclosed_quantity"] = order.disclosed_quantity + + data = await self._request( + "POST", + f"/orders/{variety}", + form_data=payload, + is_order=True, + ) + logger.info( + "Order placed: %s %s qty=%d via Kite Connect", + order.side, + order.symbol, + order.quantity, + ) + + if isinstance(data, dict): + return OrderResponse( + order_id=str(data.get("order_id", "")), + status="PENDING", + message="Order placed successfully", + ) + return OrderResponse( + order_id=str(data) if data else "", + status="PENDING", + message="Order placed", + ) + + async def modify_order(self, req: ModifyOrderRequest) -> OrderResponse: + """Modify a pending order. + + Endpoint: ``PUT /orders/{variety}/{order_id}`` + """ + payload: dict[str, Any] = {} + if req.quantity is not None: + payload["quantity"] = req.quantity + if req.price is not None: + payload["price"] = req.price + if req.order_type is not None: + payload["order_type"] = req.order_type.value + + data = await self._request( + "PUT", + f"/orders/regular/{req.order_id}", + form_data=payload, + is_order=True, + ) + if isinstance(data, dict): + return OrderResponse( + order_id=str(data.get("order_id", req.order_id)), + status="PENDING", + message="Order modified", + ) + return OrderResponse(order_id=req.order_id, status="PENDING", message="Modified") + + async def cancel_order(self, req: CancelOrderRequest) -> OrderResponse: + """Cancel a pending order. + + Endpoint: ``DELETE /orders/{variety}/{order_id}`` + """ + data = await self._request( + "DELETE", + f"/orders/regular/{req.order_id}", + is_order=True, + ) + if isinstance(data, dict): + return OrderResponse( + order_id=str(data.get("order_id", req.order_id)), + status="CANCELLED", + message="Order cancelled", + ) + return OrderResponse( + order_id=req.order_id, status="CANCELLED", message="Cancelled" + ) + + async def get_order_book(self) -> list[dict[str, Any]]: + """Fetch all orders for the day. + + Endpoint: ``GET /orders`` + """ + data = await self._request("GET", "/orders") + if isinstance(data, list): + return data + return [] + + async def get_orders(self) -> list[OrderResponse]: + """Fetch today's orders as OrderResponse models.""" + raw_orders = await self.get_order_book() + return [ + OrderResponse( + order_id=str(o.get("order_id", "")), + status=str(o.get("status", "")), + message=str(o.get("status_message", "")), + exchange_order_id=o.get("exchange_order_id"), + ) + for o in raw_orders + ] + + async def get_trades(self, order_id: str) -> list[dict[str, Any]]: + """Fetch trades for an order. + + Endpoint: ``GET /orders/{order_id}/trades`` + """ + data = await self._request("GET", f"/orders/{order_id}/trades") + if isinstance(data, list): + return data + return [] + + async def get_trade_book(self, segment: str = "EQUITY") -> list[dict[str, Any]]: + """Fetch all trades. + + Endpoint: ``GET /trades`` + """ + data = await self._request("GET", "/trades") + if isinstance(data, list): + return data + return [] + + # ── Portfolio ───────────────────────────────────────────────────────── + + async def get_positions(self) -> list[Position]: + """Fetch current positions (net + day). + + Endpoint: ``GET /portfolio/positions`` + + Kite returns ``{"net": [...], "day": [...]}``. We use net positions + to match INDstocksClient behaviour. + """ + data = await self._request("GET", "/portfolio/positions") + positions: list[Position] = [] + + if isinstance(data, dict): + net_positions = data.get("net", []) + elif isinstance(data, list): + net_positions = data + else: + net_positions = [] + + for p in net_positions: + if not isinstance(p, dict): + continue + positions.append( + Position( + symbol=p.get("tradingsymbol", ""), + exchange=p.get("exchange", ""), + product=p.get("product", ""), + quantity=p.get("quantity", 0), + average_price=float(p.get("average_price", 0)), + last_price=float(p.get("last_price", 0)), + pnl=float(p.get("pnl", 0)), + day_pnl=float(p.get("day_m2m", 0)), + buy_quantity=p.get("buy_quantity", 0), + sell_quantity=p.get("sell_quantity", 0), + buy_value=float(p.get("buy_value", 0)), + sell_value=float(p.get("sell_value", 0)), + ) + ) + return positions + + async def get_holdings(self) -> list[Holding]: + """Fetch delivery holdings. + + Endpoint: ``GET /portfolio/holdings`` + """ + data = await self._request("GET", "/portfolio/holdings") + holdings: list[Holding] = [] + + raw = data if isinstance(data, list) else [] + for h in raw: + if not isinstance(h, dict): + continue + avg_price = float(h.get("average_price", 0)) + last_price = float(h.get("last_price", 0)) + qty = int(h.get("quantity", 0)) + pnl = (last_price - avg_price) * qty if avg_price > 0 else 0 + day_change = float(h.get("day_change", 0)) + day_change_pct = float(h.get("day_change_percentage", 0)) + + holdings.append( + Holding( + symbol=h.get("tradingsymbol", ""), + exchange=h.get("exchange", ""), + quantity=qty, + average_price=avg_price, + last_price=last_price, + pnl=pnl, + day_change=day_change, + day_change_pct=day_change_pct, + ) + ) + return holdings + + async def get_funds(self) -> Funds: + """Fetch available funds and margin. + + Endpoint: ``GET /user/margins`` + + Kite returns margins per segment: ``{"equity": {...}, "commodity": {...}}``. + """ + data = await self._request("GET", "/user/margins") + if isinstance(data, dict): + equity = data.get("equity", {}) + available = float(equity.get("available", {}).get("live_balance", 0)) + used = float(equity.get("utilised", {}).get("debits", 0)) + collateral = float( + equity.get("available", {}).get("collateral", 0) + ) + + return Funds( + available_cash=available, + available_margin=available, + used_margin=used, + total_collateral=available + collateral, + ) + return Funds() + + # ── User ───────────────────────────────────────────────────────────── + + async def get_profile(self) -> UserProfile: + """Fetch authenticated user's profile. + + Endpoint: ``GET /user/profile`` + """ + data = await self._request("GET", "/user/profile") + if isinstance(data, dict): + return UserProfile( + user_id=data.get("user_id", ""), + name=data.get("user_name", ""), + email=data.get("email", ""), + broker="Kite", + ) + return UserProfile(broker="Kite") + + # ── Instrument Resolution ───────────────────────────────────────────── + + async def _load_instruments(self) -> None: + """Load and cache NSE instrument tokens from Kite instruments API.""" + if self._instruments_loaded: + return + + try: + csv_text = await self.get_instruments("equity") + lines = csv_text.strip().split("\n") + if len(lines) < 2: + return + + # Parse header to find column indices + header = lines[0].split(",") + try: + token_idx = header.index("instrument_token") + symbol_idx = header.index("tradingsymbol") + exchange_idx = header.index("exchange") + except ValueError: + logger.warning("Unexpected Kite instruments CSV header: %s", header[:5]) + return + + for line in lines[1:]: + cols = line.split(",") + if len(cols) > max(token_idx, symbol_idx, exchange_idx): + exchange = cols[exchange_idx].strip() + symbol = cols[symbol_idx].strip() + try: + token = int(cols[token_idx].strip()) + self._instruments[f"{exchange}:{symbol}"] = token + except ValueError: + continue + + self._instruments_loaded = True + logger.info( + "Loaded %d Kite instruments", len(self._instruments) + ) + except Exception: + logger.warning("Failed to load Kite instruments", exc_info=True) + + async def _resolve_instrument_token(self, scrip_code: str) -> int: + """Resolve a symbol to its Kite instrument token. + + Args: + scrip_code: Either ``NSE:RELIANCE``, plain ``RELIANCE``, + or a numeric instrument token string. + """ + # If already numeric, return directly + try: + return int(scrip_code) + except ValueError: + pass + + instrument_key = self._normalize_instrument_key(scrip_code) + + # Check cache + if instrument_key in self._instruments: + return self._instruments[instrument_key] + + # Load instruments if not yet loaded + await self._load_instruments() + + if instrument_key in self._instruments: + return self._instruments[instrument_key] + + raise KiteBrokerError( + f"Cannot resolve instrument token for '{scrip_code}'. " + "Ensure the symbol exists on NSE." + ) + + # ── Helpers ──────────────────────────────────────────────────────────── + + @staticmethod + def _normalize_instrument_key(scrip_code: str) -> str: + """Normalize a symbol to ``EXCHANGE:SYMBOL`` format. + + Examples: + ``RELIANCE`` → ``NSE:RELIANCE`` + ``NSE:RELIANCE`` → ``NSE:RELIANCE`` + ``NSE_2885`` → ``NSE:NSE_2885`` (INDstocks format, best-effort) + """ + if ":" in scrip_code: + return scrip_code + return f"NSE:{scrip_code}" + + @staticmethod + def _parse_quote( + quote_data: dict[str, Any], symbol: str, instrument_key: str + ) -> Quote: + """Parse a Kite quote response into our Quote model. + + Kite quote fields: + last_price, ohlc.open, ohlc.high, ohlc.low, ohlc.close, + volume, net_change, change (%), depth.buy[0].price, depth.sell[0].price + """ + if not isinstance(quote_data, dict): + return Quote(symbol=symbol) + + ohlc = quote_data.get("ohlc", {}) + depth = quote_data.get("depth", {}) + buy_depth = depth.get("buy", [{}]) + sell_depth = depth.get("sell", [{}]) + + exchange = instrument_key.split(":")[0] if ":" in instrument_key else "NSE" + + return Quote( + symbol=symbol, + exchange=exchange, + ltp=float(quote_data.get("last_price", 0)), + open=float(ohlc.get("open", 0)), + high=float(ohlc.get("high", 0)), + low=float(ohlc.get("low", 0)), + close=float(ohlc.get("close", 0)), + volume=int(quote_data.get("volume", 0)), + change=float(quote_data.get("net_change", 0)), + change_pct=float(quote_data.get("change", 0)), + bid=float(buy_depth[0].get("price", 0)) if buy_depth else 0.0, + ask=float(sell_depth[0].get("price", 0)) if sell_depth else 0.0, + ) + + @staticmethod + def _map_interval(interval: str) -> str: + """Map INDstocks-style interval to Kite interval. + + INDstocks: 1day, 1week, 1month, 1minute, 5minute, 15minute, 30minute, 60minute + Kite: day, week, month, minute, 5minute, 15minute, 30minute, 60minute + """ + mapping = { + "1day": "day", + "1week": "week", + "1month": "month", + "1minute": "minute", + "1second": "minute", # Kite doesn't support 1s, fallback + } + return mapping.get(interval, interval) diff --git a/skopaq/broker/kite_token_manager.py b/skopaq/broker/kite_token_manager.py new file mode 100644 index 0000000..5e6c80b --- /dev/null +++ b/skopaq/broker/kite_token_manager.py @@ -0,0 +1,269 @@ +"""Kite Connect token lifecycle management. + +Kite Connect uses a 2-step OAuth flow: + 1. User logs in at ``https://kite.zerodha.com/connect/login?v=3&api_key=xxx`` + 2. Redirected back with ``request_token`` in query params + 3. Exchange ``request_token`` for ``access_token`` via API call + 4. ``access_token`` is valid from login until ~6:00 AM next day + +This module manages the ``access_token`` lifecycle: + - Encrypted storage at rest (same pattern as INDstocks TokenManager) + - Token health checks with expiry warnings + - ``generate_session()`` to exchange request_token → access_token + +Unlike INDstocks (24-hour rolling), Kite tokens expire at a fixed daily time +(~6:00 AM IST), so we always set expiry to next 6 AM. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from dataclasses import dataclass +from datetime import datetime, time, timedelta, timezone +from pathlib import Path +from typing import Optional + +import httpx + +from cryptography.fernet import Fernet + +logger = logging.getLogger(__name__) + +_IST = timezone(timedelta(hours=5, minutes=30)) + +KITE_TOKEN_DIR = Path.home() / ".skopaq" +KITE_TOKEN_FILE = KITE_TOKEN_DIR / "kite_token.enc" +KITE_KEY_FILE = KITE_TOKEN_DIR / "kite_token.key" + +# Kite sessions expire at ~6:00 AM IST daily +KITE_SESSION_EXPIRY_TIME = time(6, 0) + +# Warn at these intervals before expiry +WARN_THRESHOLDS = [ + timedelta(hours=2), + timedelta(hours=1), + timedelta(minutes=30), + timedelta(minutes=10), +] + +# Kite Connect session API +KITE_API_BASE = "https://api.kite.trade" + + +@dataclass +class KiteTokenHealth: + """Current Kite token status.""" + + valid: bool + access_token: str = "" + expires_at: Optional[datetime] = None + remaining: Optional[timedelta] = None + warning: str = "" + + +class KiteTokenManager: + """Manages Kite Connect access_token encryption, storage, and expiry. + + Usage:: + + mgr = KiteTokenManager() + + # Option 1: Set access_token directly (if obtained externally) + mgr.set_token("your_access_token") + + # Option 2: Exchange request_token for access_token + await mgr.generate_session(api_key, api_secret, request_token) + + # Get token for API calls + token = mgr.get_token() + """ + + def __init__(self) -> None: + self._fernet: Optional[Fernet] = None + self._warned_thresholds: set[int] = set() + + def _ensure_key(self) -> Fernet: + """Load or create encryption key.""" + if self._fernet is not None: + return self._fernet + + KITE_TOKEN_DIR.mkdir(parents=True, exist_ok=True) + + if KITE_KEY_FILE.exists(): + key = KITE_KEY_FILE.read_bytes() + else: + key = Fernet.generate_key() + KITE_KEY_FILE.write_bytes(key) + KITE_KEY_FILE.chmod(0o600) + + self._fernet = Fernet(key) + return self._fernet + + def set_token(self, access_token: str) -> None: + """Encrypt and store an access token. + + Kite tokens expire at ~6:00 AM IST daily. If current time is + before 6 AM, expiry is today at 6 AM; otherwise tomorrow at 6 AM. + + Args: + access_token: The access_token from Kite session API. + """ + expires_at = self._next_expiry() + fernet = self._ensure_key() + payload = json.dumps({ + "access_token": access_token, + "expires_at": expires_at.isoformat(), + "stored_at": datetime.now(timezone.utc).isoformat(), + }) + encrypted = fernet.encrypt(payload.encode()) + KITE_TOKEN_FILE.write_bytes(encrypted) + KITE_TOKEN_FILE.chmod(0o600) + self._warned_thresholds.clear() + logger.info("Kite token stored, expires at %s", expires_at.isoformat()) + + async def generate_session( + self, api_key: str, api_secret: str, request_token: str, + ) -> str: + """Exchange request_token for access_token and store it. + + This calls ``POST /session/token`` on Kite Connect API. + + Args: + api_key: Kite Connect app API key. + api_secret: Kite Connect app API secret. + request_token: The request_token from login redirect. + + Returns: + The access_token string. + """ + # Kite requires checksum = SHA256(api_key + request_token + api_secret) + checksum = hashlib.sha256( + f"{api_key}{request_token}{api_secret}".encode() + ).hexdigest() + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{KITE_API_BASE}/session/token", + data={ + "api_key": api_key, + "request_token": request_token, + "checksum": checksum, + }, + ) + + if resp.status_code >= 400: + raise KiteTokenExpiredError( + f"Session generation failed ({resp.status_code}): {resp.text}" + ) + + data = resp.json() + if isinstance(data, dict) and data.get("status") == "error": + raise KiteTokenExpiredError( + f"Session generation failed: {data.get('message', 'Unknown error')}" + ) + + session_data = data.get("data", data) if isinstance(data, dict) else {} + access_token = session_data.get("access_token", "") + + if not access_token: + raise KiteTokenExpiredError( + "No access_token in session response" + ) + + self.set_token(access_token) + logger.info("Kite session generated successfully") + return access_token + + def get_health(self) -> KiteTokenHealth: + """Check current token validity and remaining time.""" + if not KITE_TOKEN_FILE.exists(): + return KiteTokenHealth( + valid=False, + warning="No Kite token stored. Run: skopaq kite login", + ) + + try: + fernet = self._ensure_key() + encrypted = KITE_TOKEN_FILE.read_bytes() + payload = json.loads(fernet.decrypt(encrypted).decode()) + except Exception as exc: + return KiteTokenHealth( + valid=False, warning=f"Kite token decryption failed: {exc}" + ) + + access_token = payload["access_token"] + expires_at = datetime.fromisoformat(payload["expires_at"]) + now = datetime.now(timezone.utc) + remaining = expires_at - now + + if remaining.total_seconds() <= 0: + return KiteTokenHealth( + valid=False, + expires_at=expires_at, + remaining=timedelta(0), + warning="Kite token EXPIRED. Login again at market open.", + ) + + warning = "" + for threshold in WARN_THRESHOLDS: + mins = int(threshold.total_seconds() / 60) + if remaining <= threshold and mins not in self._warned_thresholds: + warning = f"Kite token expires in {remaining}. Login again before market open." + self._warned_thresholds.add(mins) + logger.warning(warning) + break + + return KiteTokenHealth( + valid=True, + access_token=access_token, + expires_at=expires_at, + remaining=remaining, + warning=warning, + ) + + def get_token(self) -> str: + """Return the current access_token or raise if expired/missing.""" + health = self.get_health() + if not health.valid: + raise KiteTokenExpiredError(health.warning) + return health.access_token + + def clear(self) -> None: + """Delete stored Kite token.""" + if KITE_TOKEN_FILE.exists(): + KITE_TOKEN_FILE.unlink() + self._warned_thresholds.clear() + logger.info("Kite token cleared") + + @staticmethod + def get_login_url(api_key: str) -> str: + """Return the Kite Connect login URL for the given API key. + + The user must visit this URL in a browser to complete OAuth login. + After login, Kite redirects to the registered redirect URL with + ``?request_token=xxx&status=success`` in the query params. + """ + return f"https://kite.zerodha.com/connect/login?v=3&api_key={api_key}" + + @staticmethod + def _next_expiry() -> datetime: + """Calculate the next Kite session expiry (6:00 AM IST). + + If current IST time is before 6 AM, expiry is today at 6 AM. + If current IST time is after 6 AM, expiry is tomorrow at 6 AM. + """ + now_ist = datetime.now(_IST) + today_expiry = datetime.combine( + now_ist.date(), KITE_SESSION_EXPIRY_TIME, tzinfo=_IST, + ) + + if now_ist < today_expiry: + return today_expiry.astimezone(timezone.utc) + else: + return (today_expiry + timedelta(days=1)).astimezone(timezone.utc) + + +class KiteTokenExpiredError(Exception): + """Raised when the Kite Connect access_token is expired or missing.""" diff --git a/skopaq/broker/market_data.py b/skopaq/broker/market_data.py new file mode 100644 index 0000000..1d54650 --- /dev/null +++ b/skopaq/broker/market_data.py @@ -0,0 +1,463 @@ +"""Market data provider with automatic source fallback. + +Provides a unified async interface for fetching quotes and LTP from +multiple sources. The provider tries each source in priority order: + + 1. **Broker API** (INDstocks or Kite Connect) — best data, real bid/ask/depth + 2. **yfinance** (no credentials) — free, covers NSE/BSE/crypto + 3. **Cached quote** — returns last known quote if all sources fail + +This allows paper trading to work without any broker credentials while +still using live broker data when available. + +Usage:: + + provider = MarketDataProvider(config) + provider.set_broker_client(client) # optional — enhances data quality + quote = await provider.get_quote("RELIANCE") + ltp = await provider.get_ltp("RELIANCE") +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Optional, Protocol + +from skopaq.broker.models import Quote + +if TYPE_CHECKING: + from skopaq.config import SkopaqConfig + +logger = logging.getLogger(__name__) + +# How old a cached quote can be before we try to refresh (seconds) +DEFAULT_STALE_THRESHOLD = 30.0 + +# yfinance is sync — we run it in a thread and cache aggressively +_YFINANCE_CACHE_TTL = 15.0 # seconds + + +class QuoteSource(Protocol): + """Protocol for any object that can provide quotes.""" + + async def get_quote(self, instrument_key: str, symbol: str = "") -> Quote: ... + async def get_ltp(self, instrument_key: str) -> float: ... + + +class MarketDataProvider: + """Multi-source market data provider with automatic fallback. + + Sources are tried in order: broker API → yfinance → cache. + All quotes are cached locally with timestamps for staleness checks. + + Args: + config: Application configuration. + stale_threshold: Seconds after which a cached quote is considered stale. + """ + + def __init__( + self, + config: Optional[SkopaqConfig] = None, + stale_threshold: float = DEFAULT_STALE_THRESHOLD, + ) -> None: + self._config = config + self._stale_threshold = stale_threshold + self._broker_client: Optional[Any] = None + self._broker_type: str = config.broker if config else "indstocks" + self._asset_class: str = config.asset_class if config else "equity" + + # Optional additional data providers + self._angelone_client: Optional[Any] = None + self._upstox_client: Optional[Any] = None + self._angelone_initialized: bool = False + self._upstox_initialized: bool = False + + # Quote cache: {symbol: (Quote, timestamp)} + self._cache: dict[str, tuple[Quote, float]] = {} + + def set_broker_client(self, client: Any) -> None: + """Attach a live broker client (INDstocksClient or KiteConnectClient). + + When set, the provider uses the broker API as the primary data source. + When not set, falls back to other providers. + """ + self._broker_client = client + + def set_angelone_client(self, client: Any) -> None: + """Attach an Angel One SmartAPI client for real-time data.""" + self._angelone_client = client + + def set_upstox_client(self, client: Any) -> None: + """Attach an Upstox API client for real-time data.""" + self._upstox_client = client + + # ── Public API ──────────────────────────────────────────────────────── + + async def get_quote(self, symbol: str) -> Quote: + """Fetch a full quote for a symbol, trying all sources. + + Returns the best available quote. Never raises — returns a + zero-valued Quote with the symbol set on total failure. + """ + # Check cache freshness + cached = self._get_cached(symbol) + if cached is not None: + return cached + + # Source 1: Broker API (Kite/INDstocks — if available) + quote = await self._fetch_broker_quote(symbol) + if quote is not None and quote.ltp > 0: + self._put_cache(symbol, quote) + return quote + + # Source 2: Angel One SmartAPI (free, real-time, real bid/ask) + quote = await self._fetch_angelone_quote(symbol) + if quote is not None and quote.ltp > 0: + self._put_cache(symbol, quote) + return quote + + # Source 3: Upstox API (free, real-time, real bid/ask) + quote = await self._fetch_upstox_quote(symbol) + if quote is not None and quote.ltp > 0: + self._put_cache(symbol, quote) + return quote + + # Source 4: yfinance (free, no credentials — delayed, synthetic bid/ask) + quote = await self._fetch_yfinance_quote(symbol) + if quote is not None and quote.ltp > 0: + self._put_cache(symbol, quote) + return quote + + # Source 5: Binance public API (for crypto) + if self._asset_class == "crypto": + quote = await self._fetch_binance_quote(symbol) + if quote is not None and quote.ltp > 0: + self._put_cache(symbol, quote) + return quote + + # Source 4: Return stale cache if available (better than nothing) + stale = self._get_cached(symbol, ignore_staleness=True) + if stale is not None: + logger.warning( + "All sources failed for %s — returning stale quote (LTP=%.2f)", + symbol, stale.ltp, + ) + return stale + + logger.warning("No market data available for %s from any source", symbol) + return Quote(symbol=symbol) + + async def get_ltp(self, symbol: str) -> float: + """Fetch just the last traded price. Returns 0.0 on failure.""" + quote = await self.get_quote(symbol) + return quote.ltp + + async def get_quotes(self, symbols: list[str]) -> list[Quote]: + """Fetch quotes for multiple symbols concurrently.""" + tasks = [self.get_quote(sym) for sym in symbols] + return await asyncio.gather(*tasks) + + def inject_quote(self, quote: Quote) -> None: + """Manually inject a quote into the cache (for WebSocket feeds).""" + self._put_cache(quote.symbol, quote) + + # ── Broker source ───────────────────────────────────────────────────── + + async def _fetch_broker_quote(self, symbol: str) -> Optional[Quote]: + """Fetch quote from the configured broker API.""" + if self._broker_client is None: + return None + + try: + if self._broker_type == "kite": + instrument_key = f"NSE:{symbol}" if ":" not in symbol else symbol + return await self._broker_client.get_quote( + instrument_key, symbol=symbol, + ) + else: + # INDstocks — need to resolve scrip code + from skopaq.broker.scrip_resolver import resolve_scrip_code + + scrip_code = await resolve_scrip_code( + self._broker_client, symbol, + ) + return await self._broker_client.get_quote( + scrip_code, symbol=symbol, + ) + except Exception: + logger.debug( + "Broker quote fetch failed for %s", symbol, exc_info=True, + ) + return None + + async def _fetch_broker_ltp(self, symbol: str) -> float: + """Fetch LTP from the configured broker API.""" + if self._broker_client is None: + return 0.0 + + try: + if self._broker_type == "kite": + instrument_key = f"NSE:{symbol}" if ":" not in symbol else symbol + return await self._broker_client.get_ltp(instrument_key) + else: + from skopaq.broker.scrip_resolver import resolve_scrip_code + + scrip_code = await resolve_scrip_code( + self._broker_client, symbol, + ) + return await self._broker_client.get_ltp(scrip_code) + except Exception: + logger.debug( + "Broker LTP fetch failed for %s", symbol, exc_info=True, + ) + return 0.0 + + # ── Angel One source ────────────────────────────────────────────────── + + async def _fetch_angelone_quote(self, symbol: str) -> Optional[Quote]: + """Fetch quote from Angel One SmartAPI (free, real-time, real bid/ask). + + Auto-initializes on first call if credentials are configured. + """ + if self._angelone_client is None and not self._angelone_initialized: + self._angelone_initialized = True + self._angelone_client = await self._try_init_angelone() + + if self._angelone_client is None: + return None + + try: + return await self._angelone_client.get_quote(symbol) + except Exception: + logger.debug( + "Angel One quote failed for %s", symbol, exc_info=True, + ) + return None + + async def _try_init_angelone(self) -> Optional[Any]: + """Try to initialize Angel One client from config credentials.""" + if self._config is None: + return None + api_key = self._config.angelone_api_key.get_secret_value() + client_id = self._config.angelone_client_id + password = self._config.angelone_password.get_secret_value() + if not (api_key and client_id and password): + return None + try: + from skopaq.broker.angelone_client import AngelOneClient + totp = self._config.angelone_totp_secret.get_secret_value() + client = AngelOneClient( + api_key=api_key, + client_id=client_id, + password=password, + totp_secret=totp, + ) + await client.__aenter__() + logger.info("Angel One SmartAPI connected (free real-time data)") + return client + except Exception: + logger.debug("Angel One init failed — skipping", exc_info=True) + return None + + # ── Upstox source ──────────────────────────────────────────────────── + + async def _fetch_upstox_quote(self, symbol: str) -> Optional[Quote]: + """Fetch quote from Upstox API (free, real-time, real bid/ask). + + Auto-initializes on first call if credentials are configured. + """ + if self._upstox_client is None and not self._upstox_initialized: + self._upstox_initialized = True + self._upstox_client = await self._try_init_upstox() + + if self._upstox_client is None: + return None + + try: + return await self._upstox_client.get_quote(symbol) + except Exception: + logger.debug( + "Upstox quote failed for %s", symbol, exc_info=True, + ) + return None + + async def _try_init_upstox(self) -> Optional[Any]: + """Try to initialize Upstox client from config credentials.""" + if self._config is None: + return None + access_token = self._config.upstox_access_token.get_secret_value() + if not access_token: + return None + try: + from skopaq.broker.upstox_client import UpstoxClient + client = UpstoxClient(access_token=access_token) + await client.__aenter__() + logger.info("Upstox API connected (free real-time data)") + return client + except Exception: + logger.debug("Upstox init failed — skipping", exc_info=True) + return None + + # ── yfinance source ─────────────────────────────────────────────────── + + async def _fetch_yfinance_quote(self, symbol: str) -> Optional[Quote]: + """Fetch quote from yfinance (free, no credentials needed). + + yfinance is synchronous, so we run it in a thread. + Indian stocks need ``.NS`` suffix, crypto uses ``BTC-USD`` format. + """ + try: + return await asyncio.to_thread(self._yfinance_sync, symbol) + except Exception: + logger.debug( + "yfinance quote failed for %s", symbol, exc_info=True, + ) + return None + + @staticmethod + def _yfinance_sync(symbol: str) -> Optional[Quote]: + """Synchronous yfinance fetch (runs in thread).""" + try: + import yfinance as yf + except ImportError: + logger.debug("yfinance not installed — skipping fallback source") + return None + + # Build the yfinance symbol + yf_symbol = _to_yfinance_symbol(symbol) + + try: + ticker = yf.Ticker(yf_symbol) + info = ticker.fast_info + + ltp = getattr(info, "last_price", 0) or 0 + if ltp <= 0: + return None + + day_high = getattr(info, "day_high", 0) or 0 + day_low = getattr(info, "day_low", 0) or 0 + open_price = getattr(info, "open", 0) or 0 + prev_close = getattr(info, "previous_close", 0) or 0 + volume = getattr(info, "last_volume", 0) or 0 + + change = round(ltp - prev_close, 2) if prev_close else 0.0 + change_pct = round( + (change / prev_close) * 100, 2 + ) if prev_close else 0.0 + + # yfinance doesn't give bid/ask from fast_info — estimate spread + spread = ltp * 0.001 # 0.1% estimated spread + bid = round(ltp - spread / 2, 2) + ask = round(ltp + spread / 2, 2) + + return Quote( + symbol=symbol, # Return original symbol, not yfinance format + exchange="NSE", + ltp=round(float(ltp), 2), + open=round(float(open_price), 2), + high=round(float(day_high), 2), + low=round(float(day_low), 2), + close=round(float(prev_close), 2), + volume=int(volume), + change=change, + change_pct=change_pct, + bid=bid, + ask=ask, + timestamp=datetime.now(timezone.utc), + ) + except Exception: + logger.debug( + "yfinance data extraction failed for %s", yf_symbol, + exc_info=True, + ) + return None + + # ── Binance public source (crypto) ───────────────────────────────────── + + async def _fetch_binance_quote(self, symbol: str) -> Optional[Quote]: + """Fetch quote from Binance public API (no auth needed, crypto only).""" + try: + from skopaq.broker.binance_client import BinanceClient + from skopaq.broker.crypto_symbols import to_binance_pair + + pair = to_binance_pair(symbol) + base_url = ( + self._config.binance_base_url + if self._config + else "https://api.binance.com" + ) + client = BinanceClient(base_url=base_url) + + async with client: + quote = await client.get_quote(pair) + # Preserve original symbol name + quote.symbol = symbol + return quote + except Exception: + logger.debug( + "Binance quote failed for %s", symbol, exc_info=True, + ) + return None + + # ── Cache management ────────────────────────────────────────────────── + + def _get_cached( + self, symbol: str, ignore_staleness: bool = False, + ) -> Optional[Quote]: + """Return cached quote if fresh enough.""" + entry = self._cache.get(symbol) + if entry is None: + return None + + quote, ts = entry + age = time.monotonic() - ts + + if ignore_staleness or age <= self._stale_threshold: + return quote + return None + + def _put_cache(self, symbol: str, quote: Quote) -> None: + """Store a quote in the cache with current timestamp.""" + self._cache[symbol] = (quote, time.monotonic()) + + def clear_cache(self) -> None: + """Clear all cached quotes.""" + self._cache.clear() + + +def _to_yfinance_symbol(symbol: str) -> str: + """Convert a trading symbol to yfinance format. + + Rules: + RELIANCE → RELIANCE.NS (Indian equity on NSE) + RELIANCE.NS → RELIANCE.NS (already formatted) + RELIANCE.BO → RELIANCE.BO (BSE) + BTCUSDT → BTC-USD (crypto) + BTC-USD → BTC-USD (already formatted) + NSE:RELIANCE → RELIANCE.NS (strip Kite prefix) + """ + # Strip Kite-style prefix + if ":" in symbol: + parts = symbol.split(":", 1) + exchange = parts[0].upper() + sym = parts[1] + if exchange == "BSE": + return f"{sym}.BO" + return f"{sym}.NS" + + # Already has yfinance suffix + if symbol.endswith((".NS", ".BO", "-USD")): + return symbol + + # Crypto detection (ends with USDT, BUSD, etc.) + crypto_quotes = ("USDT", "BUSD", "USDC", "BTC", "ETH") + for quote_currency in crypto_quotes: + if symbol.endswith(quote_currency) and len(symbol) > len(quote_currency): + base = symbol[: -len(quote_currency)] + return f"{base}-USD" + + # Default: Indian equity on NSE + return f"{symbol}.NS" diff --git a/skopaq/broker/models.py b/skopaq/broker/models.py index 951cc74..f1c2240 100644 --- a/skopaq/broker/models.py +++ b/skopaq/broker/models.py @@ -47,15 +47,16 @@ class OrderType(StrEnum): class Product(StrEnum): - """INDstocks product types. + """Product types used across brokers. - API docs use INTRADAY/MARGIN/CNC. We also keep MIS/NRML as aliases - so internal code can use either naming convention. + INDstocks API uses INTRADAY/MARGIN/CNC. + Kite Connect uses MIS/NRML/CNC. + We keep both naming conventions as aliases. """ CNC = "CNC" # Cash and Carry (delivery) - INTRADAY = "INTRADAY" # Intraday - MARGIN = "MARGIN" # Margin - # Aliases for internal code that uses Zerodha-style names + INTRADAY = "INTRADAY" # Intraday (INDstocks) + MARGIN = "MARGIN" # Margin (INDstocks) + # Aliases for Zerodha/Kite-style names MIS = "INTRADAY" NRML = "MARGIN" diff --git a/skopaq/broker/paper_engine.py b/skopaq/broker/paper_engine.py index 1dacbe5..457ddfd 100644 --- a/skopaq/broker/paper_engine.py +++ b/skopaq/broker/paper_engine.py @@ -3,15 +3,21 @@ Maintains virtual capital, positions, and P&L. Uses the same interface as live order execution so switching from paper → live only changes the execution backend. + +When a ``MarketDataProvider`` is attached, the engine auto-refreshes +quotes before every fill and P&L calculation — no manual quote injection +needed. Without a provider, the engine still works via explicit +``update_quote()`` calls (backward-compatible). """ from __future__ import annotations +import asyncio import logging import random from datetime import datetime, timezone from decimal import Decimal -from typing import Optional +from typing import TYPE_CHECKING, Optional from uuid import uuid4 from skopaq.broker.models import ( @@ -30,6 +36,9 @@ TradingSignal, ) +if TYPE_CHECKING: + from skopaq.broker.market_data import MarketDataProvider + logger = logging.getLogger(__name__) # Default simulation parameters @@ -46,6 +55,7 @@ class PaperEngine: initial_capital: Starting cash in INR (default 10 lakh). slippage_pct: Simulated slippage as a fraction (default 0.1%). brokerage: Flat brokerage per order in INR (default 5). + market_data: Optional MarketDataProvider for auto-refreshing quotes. """ def __init__( @@ -55,6 +65,7 @@ def __init__( brokerage: float = DEFAULT_BROKERAGE_INR, brokerage_pct: float = 0.0, currency_label: str = "INR", + market_data: Optional[MarketDataProvider] = None, ) -> None: self._initial_capital = Decimal(str(initial_capital)) self._cash = Decimal(str(initial_capital)) @@ -62,6 +73,7 @@ def __init__( self._brokerage = Decimal(str(brokerage)) self._brokerage_pct = Decimal(str(brokerage_pct)) self._currency_label = currency_label + self._market_data = market_data # {symbol: Position} self._positions: dict[str, Position] = {} @@ -69,25 +81,72 @@ def __init__( # Completed order history self._orders: list[OrderResponse] = [] - # Latest quotes cache — updated by websocket or manual refresh + # Latest quotes cache — updated by websocket, manual refresh, + # or auto-refreshed by MarketDataProvider before fills self._quotes: dict[str, Quote] = {} # Daily P&L tracking self._day_pnl = Decimal("0") self._trade_count = 0 + # ── Market data source ────────────────────────────────────────────── + + def set_market_data(self, provider: MarketDataProvider) -> None: + """Attach a MarketDataProvider for automatic quote refresh. + + When set, the engine fetches fresh quotes from broker/yfinance/cache + before every fill — no manual ``update_quote()`` calls needed. + """ + self._market_data = provider + # ── Quote management ───────────────────────────────────────────────── def update_quote(self, quote: Quote) -> None: - """Update cached quote for a symbol (called by websocket handler).""" + """Update cached quote for a symbol. + + Called by WebSocket handlers, manual injection, or the + MarketDataProvider during auto-refresh. + """ self._quotes[quote.symbol] = quote + # Also push to MarketDataProvider cache if attached + if self._market_data is not None: + self._market_data.inject_quote(quote) def get_quote(self, symbol: str) -> Optional[Quote]: """Get the last known quote for a symbol.""" return self._quotes.get(symbol) + async def refresh_quote(self, symbol: str) -> Optional[Quote]: + """Fetch a fresh quote via the MarketDataProvider. + + Falls back to the cached quote if no provider is attached. + """ + if self._market_data is not None: + quote = await self._market_data.get_quote(symbol) + if quote.ltp > 0: + self._quotes[symbol] = quote + return quote + return self._quotes.get(symbol) + # ── Order execution ────────────────────────────────────────────────── + async def execute_order_async( + self, + order: OrderRequest, + signal: Optional[TradingSignal] = None, + ) -> ExecutionResult: + """Simulate order execution with auto-refreshed quotes. + + Like ``execute_order()`` but fetches a fresh quote from the + MarketDataProvider before filling. Use this when a provider + is attached for live-like paper trading. + """ + if self._market_data is not None: + quote = await self._market_data.get_quote(order.symbol) + if quote.ltp > 0: + self._quotes[order.symbol] = quote + return self.execute_order(order, signal) + def execute_order( self, order: OrderRequest, @@ -97,6 +156,9 @@ def execute_order( Returns an ``ExecutionResult`` indicating success/failure, fill price, slippage, and brokerage. + + Prefer ``execute_order_async()`` when a MarketDataProvider is + attached — it auto-refreshes quotes before filling. """ quote = self._quotes.get(order.symbol) if quote is None: @@ -297,6 +359,21 @@ def get_positions(self) -> list[Position]: result.append(pos) return result + async def get_positions_live(self) -> list[Position]: + """Return positions with live-refreshed P&L from MarketDataProvider. + + Like ``get_positions()`` but fetches fresh quotes for all open + positions before computing P&L. + """ + if self._market_data is not None: + symbols = list(self._positions.keys()) + if symbols: + quotes = await self._market_data.get_quotes(symbols) + for q in quotes: + if q.ltp > 0: + self._quotes[q.symbol] = q + return self.get_positions() + def get_holdings(self) -> list[Holding]: """Return positions formatted as holdings (for CNC delivery positions).""" return [ diff --git a/skopaq/broker/upstox_client.py b/skopaq/broker/upstox_client.py new file mode 100644 index 0000000..a445536 --- /dev/null +++ b/skopaq/broker/upstox_client.py @@ -0,0 +1,272 @@ +"""Upstox API v2 client for free real-time Indian market data. + +Upstox provides free API access for NSE/BSE market data including +real-time quotes with bid/ask, market depth, and historical OHLCV. + +Authentication: OAuth2 → access_token (valid for 1 day). +Base URL: https://api.upstox.com/v2 + +Usage:: + + async with UpstoxClient(access_token) as client: + quote = await client.get_quote("RELIANCE", "NSE_EQ") + ltp = await client.get_ltp("RELIANCE", "NSE_EQ") + +Setup: + 1. Open free Upstox demat account at upstox.com + 2. Create app at upstox.com/developer/apps → get API key + secret + 3. Complete OAuth2 login → get access_token + 4. Set SKOPAQ_UPSTOX_ACCESS_TOKEN in .env +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +import httpx + +from skopaq.broker.models import HistoricalCandle, Quote +from skopaq.broker.rate_limiter import RateLimiter + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://api.upstox.com/v2" +_api_limiter = RateLimiter(max_calls=25, period=1.0) + +# Upstox instrument key format: EXCHANGE_SEGMENT|TRADINGSYMBOL +# e.g., NSE_EQ|INE002A01018 (ISIN-based) or NSE_EQ|RELIANCE +_EXCHANGE_SEGMENT_MAP = { + "NSE": "NSE_EQ", + "BSE": "BSE_EQ", + "NFO": "NSE_FO", + "MCX": "MCX_FO", +} + + +class UpstoxError(Exception): + def __init__(self, message: str, status_code: int = 0) -> None: + super().__init__(message) + self.status_code = status_code + + +class UpstoxClient: + """Async REST client for Upstox API v2. + + Provides free real-time market data (quotes, LTP, depth) and + historical OHLCV candles for NSE/BSE equities. + + Usage:: + + async with UpstoxClient(access_token) as client: + quote = await client.get_quote("RELIANCE") + """ + + def __init__( + self, + access_token: str, + timeout: float = 30.0, + ) -> None: + self._access_token = access_token + self._timeout = timeout + self._client: Optional[httpx.AsyncClient] = None + # Cache: {symbol: instrument_key} + self._instrument_cache: dict[str, str] = {} + + async def __aenter__(self) -> UpstoxClient: + self._client = httpx.AsyncClient( + base_url=_BASE_URL, + timeout=self._timeout, + limits=httpx.Limits(max_connections=10, max_keepalive_connections=5), + ) + return self + + async def __aexit__(self, *exc: object) -> None: + if self._client: + await self._client.aclose() + self._client = None + + # ── Request helper ─────────────────────────────────────────────────── + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._access_token}", + "Accept": "application/json", + } + + async def _request( + self, method: str, path: str, + params: Optional[dict[str, str]] = None, + body: Optional[dict] = None, + ) -> Any: + """Send authenticated request to Upstox API v2.""" + if self._client is None: + raise UpstoxError("Client not initialised") + + await _api_limiter.acquire() + + try: + resp = await self._client.request( + method, path, + headers=self._headers(), + params=params, + json=body, + ) + except httpx.HTTPError as exc: + raise UpstoxError(f"HTTP error: {exc}") from exc + + if resp.status_code >= 400: + raise UpstoxError( + f"API error {resp.status_code}: {resp.text}", + status_code=resp.status_code, + ) + + result = resp.json() + if result.get("status") == "error": + errors = result.get("errors", [{}]) + msg = errors[0].get("message", "Unknown error") if errors else "Unknown error" + raise UpstoxError(msg) + + return result.get("data", {}) + + # ── Market Data ────────────────────────────────────────────────────── + + async def get_quote(self, symbol: str, exchange: str = "NSE") -> Quote: + """Fetch full quote with real bid/ask. + + Endpoint: GET /market-quote/quotes?instrument_key=NSE_EQ|RELIANCE + """ + instrument_key = self._build_instrument_key(symbol, exchange) + + data = await self._request( + "GET", "/market-quote/quotes", + params={"instrument_key": instrument_key}, + ) + + # Response: {"NSE_EQ:RELIANCE": {"ohlc": {...}, "depth": {...}, ...}} + if not isinstance(data, dict): + return Quote(symbol=symbol, exchange=exchange) + + # Data is keyed by instrument_key format + quote_data = None + for key, val in data.items(): + quote_data = val + break + + if not quote_data: + return Quote(symbol=symbol, exchange=exchange) + + ohlc = quote_data.get("ohlc", {}) + depth = quote_data.get("depth", {}) + buy_depth = depth.get("buy", [{}]) + sell_depth = depth.get("sell", [{}]) + + ltp = float(quote_data.get("last_price", 0)) + prev_close = float(ohlc.get("close", 0)) + change = ltp - prev_close if prev_close else 0.0 + change_pct = (change / prev_close * 100) if prev_close else 0.0 + + return Quote( + symbol=symbol, + exchange=exchange, + ltp=ltp, + open=float(ohlc.get("open", 0)), + high=float(ohlc.get("high", 0)), + low=float(ohlc.get("low", 0)), + close=prev_close, + volume=int(quote_data.get("volume", 0)), + change=round(change, 2), + change_pct=round(change_pct, 2), + bid=float(buy_depth[0].get("price", 0)) if buy_depth else 0.0, + ask=float(sell_depth[0].get("price", 0)) if sell_depth else 0.0, + timestamp=datetime.now(timezone.utc), + ) + + async def get_ltp(self, symbol: str, exchange: str = "NSE") -> float: + """Fetch just the last traded price. + + Endpoint: GET /market-quote/ltp?instrument_key=NSE_EQ|RELIANCE + """ + instrument_key = self._build_instrument_key(symbol, exchange) + + data = await self._request( + "GET", "/market-quote/ltp", + params={"instrument_key": instrument_key}, + ) + + if isinstance(data, dict): + for key, val in data.items(): + return float(val.get("last_price", 0)) + return 0.0 + + async def get_historical( + self, + symbol: str, + exchange: str = "NSE", + interval: str = "day", + from_date: str = "", + to_date: str = "", + ) -> list[HistoricalCandle]: + """Fetch historical OHLCV candles. + + Endpoint: GET /historical-candle/{instrument_key}/{interval}/{to_date}/{from_date} + + Args: + interval: 1minute, 5minute, 15minute, 30minute, day, week, month + """ + instrument_key = self._build_instrument_key(symbol, exchange) + + if not to_date: + to_date = datetime.now().strftime("%Y-%m-%d") + if not from_date: + from_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + + data = await self._request( + "GET", + f"/historical-candle/{instrument_key}/{interval}/{to_date}/{from_date}", + ) + + candles_raw = data.get("candles", []) if isinstance(data, dict) else [] + candles = [] + for row in candles_raw: + # [timestamp, open, high, low, close, volume, oi] + if isinstance(row, list) and len(row) >= 6: + try: + dt = datetime.fromisoformat(str(row[0])) + except (ValueError, TypeError): + dt = datetime.now() + candles.append(HistoricalCandle( + timestamp=dt, + open=float(row[1]), + high=float(row[2]), + low=float(row[3]), + close=float(row[4]), + volume=int(row[5]), + )) + return candles + + # ── Helpers ────────────────────────────────────────────────────────── + + @staticmethod + def _build_instrument_key(symbol: str, exchange: str = "NSE") -> str: + """Build Upstox instrument key from symbol and exchange. + + Format: NSE_EQ|RELIANCE + """ + segment = _EXCHANGE_SEGMENT_MAP.get(exchange.upper(), "NSE_EQ") + return f"{segment}|{symbol.upper()}" + + @staticmethod + def get_login_url(api_key: str, redirect_uri: str) -> str: + """Return the Upstox OAuth2 login URL. + + After login, Upstox redirects to redirect_uri with ?code=xxx. + Exchange the code for access_token via POST /login/authorization/token. + """ + return ( + f"https://api.upstox.com/v2/login/authorization/dialog" + f"?client_id={api_key}" + f"&redirect_uri={redirect_uri}" + f"&response_type=code" + ) diff --git a/skopaq/cli/display.py b/skopaq/cli/display.py index bcb7e43..a361c36 100644 --- a/skopaq/cli/display.py +++ b/skopaq/cli/display.py @@ -64,7 +64,7 @@ def display_welcome() -> None: "AI algorithmic trading platform for Indian equities\n", style="bold white", ) - content.append("Integrates with INDstocks ", style=DIM) + content.append("Multi-Broker (INDstocks | Kite | Angel One | Upstox) ", style=DIM) content.append("| ", style=DIM) content.append("Built on TradingAgents", style=DIM) content.append("\n\n") @@ -118,7 +118,13 @@ def display_status( table.add_row("Mode", f"[{mode_style}]{mode}[/{mode_style}]") # Broker - table.add_row("Broker", f"INDstocks ({config.indstocks_base_url})") + broker_name = config.broker.capitalize() if hasattr(config, "broker") else "INDstocks" + table.add_row("Broker", f"[{ACCENT}]{broker_name}[/{ACCENT}]") + + # Product type + product = getattr(config, "default_product", "CNC") + product_label = {"CNC": "CNC (Delivery)", "MIS": "MIS (Intraday)", "NRML": "NRML (F&O)"}.get(product, product) + table.add_row("Product", product_label) # Token if health.valid: @@ -613,6 +619,14 @@ def display_daemon_start(config: SkopaqConfig) -> None: mode = config.trading_mode.upper() mode_style = SUCCESS if mode == "PAPER" else "bold red" table.add_row("Mode", f"[{mode_style}]{mode}[/{mode_style}]") + + broker_name = config.broker.capitalize() if hasattr(config, "broker") else "INDstocks" + table.add_row("Broker", f"[{ACCENT}]{broker_name}[/{ACCENT}]") + + product = getattr(config, "default_product", "CNC") + product_label = {"CNC": "Delivery (CNC)", "MIS": "Intraday (MIS)", "NRML": "F&O (NRML)"}.get(product, product) + table.add_row("Product", product_label) + table.add_row("Max Trades", str(config.daemon_max_trades_per_session)) table.add_row("Max Candidates", str(config.daemon_max_candidates_to_analyze)) table.add_row("Scan Delay", f"{config.daemon_scan_delay_after_open_seconds}s after open") diff --git a/skopaq/cli/main.py b/skopaq/cli/main.py index 3dc8ea4..c08301f 100644 --- a/skopaq/cli/main.py +++ b/skopaq/cli/main.py @@ -94,18 +94,120 @@ def token_clear() -> None: display_success("Token cleared") +# ── Kite Connect token management ──────────────────────────────────────────── + +kite_app = typer.Typer(help="Kite Connect (Zerodha) token management.") +app.add_typer(kite_app, name="kite") + + +@kite_app.command("login-url") +def kite_login_url() -> None: + """Print the Kite Connect login URL.""" + from skopaq.broker.kite_token_manager import KiteTokenManager + from skopaq.config import SkopaqConfig + + config = SkopaqConfig() + api_key = config.kite_api_key.get_secret_value() + if not api_key: + display_error("SKOPAQ_KITE_API_KEY not set in .env") + raise typer.Exit(code=1) + + url = KiteTokenManager.get_login_url(api_key) + display_success(f"Open this URL in your browser:\n{url}") + + +@kite_app.command("set-token") +def kite_set_token( + access_token: str = typer.Argument(..., help="Access token from Kite session."), +) -> None: + """Store a Kite Connect access token.""" + from skopaq.broker.kite_token_manager import KiteTokenManager + + mgr = KiteTokenManager() + mgr.set_token(access_token) + health = mgr.get_health() + if health.valid: + display_success( + f"Kite token stored. Expires at {health.expires_at}" + ) + else: + display_error(health.warning) + + +@kite_app.command("session") +def kite_session( + request_token: str = typer.Argument(..., help="Request token from login redirect URL."), +) -> None: + """Exchange request_token for access_token and store it.""" + from skopaq.broker.kite_token_manager import KiteTokenManager + from skopaq.config import SkopaqConfig + + config = SkopaqConfig() + api_key = config.kite_api_key.get_secret_value() + api_secret = config.kite_api_secret.get_secret_value() + + if not api_key or not api_secret: + display_error("SKOPAQ_KITE_API_KEY and SKOPAQ_KITE_API_SECRET must be set in .env") + raise typer.Exit(code=1) + + mgr = KiteTokenManager() + + async def _generate(): + return await mgr.generate_session(api_key, api_secret, request_token) + + try: + access_token = asyncio.run(_generate()) + display_success(f"Kite session established. Token: {access_token[:8]}...") + except Exception as exc: + display_error(f"Kite session failed: {exc}") + raise typer.Exit(code=1) + + +@kite_app.command("status") +def kite_status() -> None: + """Check Kite Connect token health.""" + from skopaq.broker.kite_token_manager import KiteTokenManager + + mgr = KiteTokenManager() + health = mgr.get_health() + if health.valid: + display_success( + f"Kite token valid. Expires in {health.remaining} (at {health.expires_at})" + ) + else: + display_error(health.warning) + raise typer.Exit(code=1) + + +@kite_app.command("clear") +def kite_clear() -> None: + """Delete stored Kite token.""" + from skopaq.broker.kite_token_manager import KiteTokenManager + + mgr = KiteTokenManager() + mgr.clear() + display_success("Kite token cleared") + + # ── Status ─────────────────────────────────────────────────────────────────── @app.command("status") def status() -> None: """Show system health overview.""" - from skopaq.broker.token_manager import TokenManager from skopaq.config import SkopaqConfig config = SkopaqConfig() - mgr = TokenManager() - health = mgr.get_health() + + # Get token health for the configured broker + if config.broker == "kite": + from skopaq.broker.kite_token_manager import KiteTokenManager + mgr = KiteTokenManager() + health = mgr.get_health() + else: + from skopaq.broker.token_manager import TokenManager + mgr = TokenManager() + health = mgr.get_health() # Detect configured LLMs llms = [] @@ -191,93 +293,164 @@ async def _run_analyze(symbol: str, trade_date: str): @app.command("trade") def trade( - symbol: str = typer.Argument(..., help="Stock symbol to trade (e.g., RELIANCE)."), + symbol: str = typer.Argument(None, help="Stock symbol (e.g., RELIANCE). Omit to auto-scan."), date: str = typer.Option("", help="Trade date (YYYY-MM-DD). Defaults to today."), + top: int = typer.Option(1, "--top", help="Number of candidates to trade (when auto-scanning)."), + watchlist: str = typer.Option("", "--watchlist", help="Comma-separated symbols to scan (instead of NIFTY 50)."), + no_monitor: bool = typer.Option(False, "--no-monitor", help="Skip position monitoring after trade."), + intraday: bool = typer.Option(False, "--intraday", help="Use MIS (intraday) product instead of CNC (delivery)."), ) -> None: - """Analyze and execute a trade for a symbol.""" + """Analyze and execute trades. + + With a symbol: skopaq trade RELIANCE (analyze + trade that symbol) + Without symbol: skopaq trade (auto-scan → trade best pick) + Multiple: skopaq trade --top 3 (scan → trade top 3 candidates) + Intraday: skopaq trade --intraday (MIS product, forced EOD exit) + Custom list: skopaq trade --watchlist "RELIANCE,TCS,INFY" + + In paper mode, the full lifecycle runs automatically: + scan → analyze → trade → monitor → close → report. + """ if not date: date = datetime.now(timezone.utc).strftime("%Y-%m-%d") from skopaq.config import SkopaqConfig config = SkopaqConfig() + # --intraday flag overrides product to MIS + if intraday: + config.default_product = "MIS" + # Double-confirmation gate for LIVE mode — real money at stake if config.trading_mode == "live": + target = symbol or f"auto-scan (top {top})" typer.echo( - "\n ⚠ LIVE TRADING MODE — real orders will be placed on INDstocks.\n" - f" Symbol: {symbol} Date: {date}\n" + f"\n !! LIVE TRADING MODE — real orders will be placed.\n" + f" Broker: {config.broker}\n" + f" Target: {target} Date: {date}\n" ) if not typer.confirm(" Proceed with LIVE execution?", default=False): typer.echo(" Aborted.") raise typer.Exit() - display_trade_start(symbol, date, config.trading_mode) - result = asyncio.run(_run_trade(symbol, date)) - - if result.error: + if symbol: + # Explicit symbol — single trade flow + display_trade_start(symbol, date, config.trading_mode) + result = asyncio.run(_run_trade_full( + symbols=[symbol], + trade_date=date, + monitor=not no_monitor, + )) + else: + # Auto-scan → trade top N candidates + wl = [s.strip() for s in watchlist.split(",") if s.strip()] if watchlist else None + typer.echo( + f"\n Auto-scan mode: finding top {top} candidate(s)" + + (f" from {wl}" if wl else " from NIFTY 50") + + f" [{config.trading_mode}]\n" + ) + result = asyncio.run(_run_trade_full( + symbols=None, + trade_date=date, + max_candidates=top, + custom_watchlist=wl, + monitor=not no_monitor, + )) + + # Display results + if isinstance(result, dict) and "report" in result: + display_daemon_report(result["report"]) + elif hasattr(result, "error") and result.error: display_error(result.error) raise typer.Exit(code=1) + elif hasattr(result, "signal"): + display_trade_result(result) + - display_trade_result(result) +async def _run_trade_full( + symbols: list[str] | None, + trade_date: str, + max_candidates: int = 1, + custom_watchlist: list[str] | None = None, + monitor: bool = True, +) -> dict: + """Full-lifecycle trade: scan → analyze → trade → monitor → close → report. + When ``symbols`` is provided, skips the scanner and trades those symbols. + When ``symbols`` is None, runs the scanner to discover candidates. -async def _run_trade(symbol: str, trade_date: str): - """Helper to run async trade. + In paper mode, the full cycle runs including position monitoring. + In live mode, monitoring is optional (controlled by ``monitor`` flag). - For paper mode, this function: - 1. Uses relaxed safety rules (no market-hours or stop-loss gate). - 2. Fetches a real-time quote from INDstocks and injects it into the - paper engine so ``execute_order()`` can simulate a fill. - 3. Wires the trade lifecycle manager for auto-reflection on SELL. + Returns a dict with ``{"report": DaemonSessionReport}`` for the CLI + to display, or a single AnalysisResult if only one symbol was traded. """ + import signal as sig + + from skopaq.broker.market_data import MarketDataProvider from skopaq.broker.paper_engine import PaperEngine from skopaq.config import SkopaqConfig from skopaq.constants import ( CRYPTO_PAPER_SAFETY_RULES, CRYPTO_SAFETY_RULES, + INTRADAY_PAPER_SAFETY_RULES, INTRADAY_SAFETY_RULES, PAPER_SAFETY_RULES, SAFETY_RULES, ) + from skopaq.execution.daemon import DaemonSessionReport from skopaq.execution.executor import Executor from skopaq.execution.order_router import OrderRouter from skopaq.execution.safety_checker import SafetyChecker from skopaq.graph.skopaq_graph import SkopaqTradingGraph - from skopaq.risk.position_sizer import PositionSizer config = SkopaqConfig() is_crypto = config.asset_class == "crypto" + is_paper = config.trading_mode == "paper" + is_intraday = config.default_product == "MIS" + + report = DaemonSessionReport( + session_date=trade_date, + ) + + # ── 1. Market data + paper engine ──────────────────────────────── + market_data = MarketDataProvider(config) - # Paper engine — crypto uses % brokerage + USDT capital if is_crypto: paper = PaperEngine( initial_capital=config.initial_paper_capital, - brokerage_pct=0.001, # 0.1% Binance spot fee + brokerage_pct=0.001, currency_label="USDT", + market_data=market_data, ) else: - paper = PaperEngine(initial_capital=config.initial_paper_capital) + paper = PaperEngine( + initial_capital=config.initial_paper_capital, + market_data=market_data, + ) - # Choose safety rules based on trading mode + asset class if is_crypto: - rules = CRYPTO_PAPER_SAFETY_RULES if config.trading_mode == "paper" else CRYPTO_SAFETY_RULES + rules = CRYPTO_PAPER_SAFETY_RULES if is_paper else CRYPTO_SAFETY_RULES + elif is_intraday: + rules = INTRADAY_PAPER_SAFETY_RULES if is_paper else INTRADAY_SAFETY_RULES else: - rules = PAPER_SAFETY_RULES if config.trading_mode == "paper" else SAFETY_RULES + rules = PAPER_SAFETY_RULES if is_paper else SAFETY_RULES - # Wire live broker client when in live mode + # ── 2. Broker client ──────────────────────────────────────────── live_client = None + broker_client = None if config.trading_mode == "live": - from skopaq.broker.client import INDstocksClient - from skopaq.broker.token_manager import TokenManager - - token_mgr = TokenManager() - live_client = INDstocksClient(config, token_mgr) + live_client = _create_live_client(config) + broker_client = live_client + else: + try: + broker_client = _create_live_client(config) + except Exception: + logger.info("No broker token — using yfinance for market data") router = OrderRouter(config, paper, live_client=live_client) safety = SafetyChecker( rules=rules, max_sector_concentration_pct=config.max_sector_concentration_pct, ) - - # ATR-based position sizer (optional — also works for crypto via yfinance) sizer = None if config.position_sizing_enabled: sizer = PositionSizer( @@ -285,123 +458,229 @@ async def _run_trade(symbol: str, trade_date: str): atr_multiplier=config.atr_multiplier, atr_period=config.atr_period, ) - - executor = Executor(router, safety, position_sizer=sizer) - - # For paper mode, inject a real-time quote so the fill simulation has a price. - if config.trading_mode == "paper": - if is_crypto: - await _inject_crypto_quote(config, paper, symbol) - else: - await _inject_paper_quote(config, paper, symbol) - - # Compute regime and calendar scales for position sizing - # (India VIX + NSE calendar are irrelevant for crypto — skip) - if is_crypto: - regime_scale, calendar_scale = 1.0, 1.0 - else: - regime_scale, calendar_scale = _compute_risk_scales(config, trade_date) - - upstream_config = _build_upstream_config(config) - - # For crypto, translate BTCUSDT → BTC-USD for yfinance-based analysis - if is_crypto: - from skopaq.broker.crypto_symbols import to_yfinance_ticker - analysis_symbol = to_yfinance_ticker(symbol) - # Store the original Binance symbol for trade record building - upstream_config["_trade_symbol"] = symbol - logger.info("Crypto: %s → analysis as %s", symbol, analysis_symbol) - else: - analysis_symbol = symbol - - # Load persisted memories + wire lifecycle manager - memory_store = _create_memory_store(config) - analysts = [a.strip() for a in config.selected_analysts.split(",") if a.strip()] - graph = SkopaqTradingGraph( - upstream_config, executor, - selected_analysts=analysts, - memory_store=memory_store, + executor = Executor( + router, safety, + position_sizer=sizer, + product=config.default_product, ) - # Open live client context if wired (INDstocksClient requires async with) - if live_client is not None: - await live_client.__aenter__() + # Open broker context + if broker_client is not None: + try: + await broker_client.__aenter__() + market_data.set_broker_client(broker_client) + except Exception: + logger.info("Broker client open failed — using yfinance fallback") + broker_client = None + # ── 3. Discover symbols (scan if needed) ───────────────────────── try: - result = await graph.analyze_and_execute( - analysis_symbol, trade_date, - regime_scale=regime_scale, - calendar_scale=calendar_scale, + if symbols: + trade_symbols = symbols + logger.info("Explicit symbols: %s", trade_symbols) + else: + logger.info("Running scanner to find top %d candidate(s)...", max_candidates) + try: + candidates = await _run_scan( + max_candidates, + ) + report.candidates_scanned = len(candidates) + trade_symbols = [c.symbol for c in candidates[:max_candidates]] + logger.info( + "Scanner found %d candidates: %s", + len(trade_symbols), trade_symbols, + ) + if not trade_symbols: + logger.info("No candidates found — nothing to trade") + return {"report": report} + except Exception as exc: + logger.error("Scanner failed: %s", exc, exc_info=True) + report.errors.append(f"Scanner error: {exc}") + return {"report": report} + + # ── 4. Analyze + trade each symbol ─────────────────────────── + upstream_config = _build_upstream_config(config) + memory_store = _create_memory_store(config) + analysts = [a.strip() for a in config.selected_analysts.split(",") if a.strip()] + graph = SkopaqTradingGraph( + upstream_config, executor, + selected_analysts=analysts, + memory_store=memory_store, ) - finally: - if live_client is not None: - await live_client.__aexit__(None, None, None) - # Post-execution: track lifecycle (BUY/SELL linkage + reflection) - if config.reflection_enabled and memory_store is not None: - await _run_lifecycle(config, graph, memory_store, result) + regime_scale, calendar_scale = ( + (1.0, 1.0) if is_crypto + else _compute_risk_scales(config, trade_date) + ) - return result + buys_placed = 0 + for sym in trade_symbols: + report.candidates_analyzed += 1 + logger.info("Analyzing %s...", sym) + + # Pre-warm quote cache + try: + await market_data.get_quote(sym) + except Exception: + logger.debug("Quote pre-warm failed for %s", sym) + try: + result = await graph.analyze_and_execute( + sym, trade_date, + regime_scale=regime_scale, + calendar_scale=calendar_scale, + ) + except Exception as exc: + logger.error("Analysis failed for %s: %s", sym, exc, exc_info=True) + report.errors.append(f"{sym}: {exc}") + continue + + if result.error: + report.errors.append(f"{sym}: {result.error}") + continue + + if result.signal is None or result.signal.action == "HOLD": + report.holds += 1 + logger.info("[%s] Decision: HOLD", sym) + continue + + if result.signal.action == "BUY": + if result.execution and result.execution.success: + buys_placed += 1 + report.trades_opened += 1 + logger.info( + "[%s] BUY EXECUTED — fill=%.2f qty=%s", + sym, + result.execution.fill_price or 0, + result.signal.quantity, + ) + + # Reflection + if config.reflection_enabled and memory_store: + try: + await _run_lifecycle(config, graph, memory_store, result) + except Exception: + logger.debug("Lifecycle failed for %s", sym, exc_info=True) + else: + report.trades_rejected += 1 + reason = ( + result.execution.rejection_reason + if result.execution else "no execution result" + ) + logger.warning("[%s] BUY REJECTED: %s", sym, reason) + + # For single-symbol non-monitored mode, return the raw result + if len(trade_symbols) == 1 and not monitor: + return result + + # ── 5. Monitor positions ───────────────────────────────────── + if monitor and buys_placed > 0: + from skopaq.execution.position_monitor import PositionMonitor -async def _inject_paper_quote(config, paper, symbol: str) -> None: - """Fetch a real quote from INDstocks and inject it into the paper engine. + logger.info( + "Starting position monitor (%d position(s))...", + buys_placed, + ) - The paper engine requires a Quote in its ``_quotes`` cache before - ``execute_order()`` can simulate a fill. Without this, it returns - "No quote available for {symbol}". - """ - from skopaq.broker.client import INDstocksClient - from skopaq.broker.scrip_resolver import resolve_scrip_code - from skopaq.broker.token_manager import TokenManager + stop_event = asyncio.Event() - token_mgr = TokenManager() - client = INDstocksClient(config, token_mgr) + def _handle_sigint(*_): + logger.info("Ctrl+C — shutting down monitor...") + stop_event.set() - try: - async with client: - scrip_code = await resolve_scrip_code(client, symbol) - logger.info("Resolved %s → %s", symbol, scrip_code) + loop = asyncio.get_running_loop() + try: + loop.add_signal_handler(sig.SIGINT, _handle_sigint) + except NotImplementedError: + pass # Windows - quote = await client.get_quote(scrip_code, symbol=symbol) - paper.update_quote(quote) - logger.info( - "Injected quote: %s LTP=%.2f bid=%.2f ask=%.2f", - symbol, quote.ltp, quote.bid, quote.ask, + # Build LLM for sell analyst + llm = None + try: + from skopaq.llm import bridge_env_vars, build_llm_map + bridge_env_vars(config) + llm_map = build_llm_map() + llm = llm_map.get("sell_analyst", llm_map.get("_default")) + except Exception: + logger.debug("No LLM for sell analyst — rule-based only") + + mon = PositionMonitor( + executor=executor, + market_data=market_data, + router=router, + config=config, + llm=llm, + stop_event=stop_event, + ai_enabled=llm is not None, ) - except Exception as exc: - logger.warning( - "Could not fetch quote for %s — paper fill may fail: %s", - symbol, exc, - ) + monitor_result = await mon.run() + report.monitor_result = monitor_result + report.sells_executed = monitor_result.sells_executed + report.sells_failed = monitor_result.sells_failed + report.gross_pnl = monitor_result.total_pnl + elif buys_placed == 0: + logger.info("No positions opened — skipping monitor") + + # ── 6. Close remaining positions ───────────────────────────── + if buys_placed > 0: + try: + positions = await router.get_positions() + open_pos = [p for p in positions if p.quantity > 0] + if open_pos: + from decimal import Decimal + from skopaq.broker.models import TradingSignal + + logger.info("Closing %d remaining position(s)...", len(open_pos)) + for pos in open_pos: + try: + signal = TradingSignal( + symbol=pos.symbol, action="SELL", + confidence=100, + entry_price=pos.average_price, + quantity=Decimal(int(pos.quantity)), + reasoning="Session close: auto-sell remaining positions", + ) + sell_result = await executor.execute_signal(signal) + if sell_result.success: + logger.info("Closed %s", pos.symbol) + else: + logger.warning( + "Close rejected for %s: %s", + pos.symbol, sell_result.rejection_reason, + ) + except Exception: + logger.error("Close failed for %s", pos.symbol, exc_info=True) + except Exception: + logger.debug("Position check failed during close", exc_info=True) + + finally: + # Clean up broker session + if broker_client is not None: + try: + await broker_client.__aexit__(None, None, None) + except Exception: + pass + return {"report": report} -async def _inject_crypto_quote(config, paper, symbol: str) -> None: - """Fetch a real quote from Binance and inject it into the paper engine. - The paper engine requires a Quote in its ``_quotes`` cache before - ``execute_order()`` can simulate a fill. Uses the Binance public - 24hr ticker endpoint (no authentication needed). +def _create_live_client(config): + """Create the appropriate live broker client based on config.broker. + + Returns an INDstocksClient or KiteConnectClient (both are async context managers). """ - from skopaq.broker.binance_client import BinanceClient - from skopaq.broker.crypto_symbols import to_binance_pair + if config.broker == "kite": + from skopaq.broker.kite_client import KiteConnectClient + from skopaq.broker.kite_token_manager import KiteTokenManager - pair = to_binance_pair(symbol) - client = BinanceClient(base_url=config.binance_base_url) + token_mgr = KiteTokenManager() + return KiteConnectClient(config, token_mgr) + else: + from skopaq.broker.client import INDstocksClient + from skopaq.broker.token_manager import TokenManager - try: - async with client: - quote = await client.get_quote(pair) - paper.update_quote(quote) - logger.info( - "Injected crypto quote: %s LTP=%.2f bid=%.2f ask=%.2f", - pair, quote.ltp, quote.bid, quote.ask, - ) - except Exception as exc: - logger.warning( - "Could not fetch crypto quote for %s — paper fill may fail: %s", - pair, exc, - ) + token_mgr = TokenManager() + return INDstocksClient(config, token_mgr) # ── Scan ────────────────────────────────────────────────────────────────────── @@ -461,41 +740,29 @@ async def quote_fetcher(symbols: list[str]) -> list[dict]: else: watchlist = Watchlist() + # Use MarketDataProvider for broker-agnostic quote fetching. + # This supports all brokers (Kite, INDstocks) + free fallbacks + # (Angel One, Upstox, yfinance) automatically. + from skopaq.broker.market_data import MarketDataProvider + + _scanner_market_data = MarketDataProvider(config) + async def quote_fetcher(symbols: list[str]) -> list[dict]: - """Batch-fetch INDstocks quotes for equity symbols.""" - from skopaq.broker.client import INDstocksClient - from skopaq.broker.scrip_resolver import resolve_scrip_code - from skopaq.broker.token_manager import TokenManager - - token_mgr = TokenManager() - async with INDstocksClient(config, token_mgr) as client: - # Resolve all symbols → scrip codes (instruments CSV cached 1h) - resolved: list[tuple[str, str]] = [] - for sym in symbols: - try: - code = await resolve_scrip_code(client, sym) - resolved.append((sym, code)) - except ValueError: - logger.debug("Scrip resolve failed: %s", sym) - - if not resolved: - return [] - - syms, codes = zip(*resolved) - raw_quotes = await client.get_quotes(list(codes), symbols=list(syms)) - - return [ - { - "symbol": q.symbol, - "ltp": q.ltp, - "open": q.open, - "high": q.high, - "low": q.low, - "close": q.close, - "volume": q.volume, - } - for q in raw_quotes - ] + """Batch-fetch quotes via MarketDataProvider (any broker/source).""" + quotes = await _scanner_market_data.get_quotes(symbols) + return [ + { + "symbol": q.symbol, + "ltp": q.ltp, + "open": q.open, + "high": q.high, + "low": q.low, + "close": q.close, + "volume": q.volume, + } + for q in quotes + if q.ltp > 0 + ] # ── LLM screener factories ─────────────────────────────────────── @@ -574,33 +841,52 @@ async def _run_monitor(config, ai_enabled: bool): """Async helper to run the position monitor.""" import signal as sig - from skopaq.broker.client import INDstocksClient - from skopaq.broker.token_manager import TokenManager + from skopaq.broker.market_data import MarketDataProvider from skopaq.constants import PAPER_SAFETY_RULES, SAFETY_RULES from skopaq.execution.executor import Executor from skopaq.execution.order_router import OrderRouter from skopaq.execution.position_monitor import PositionMonitor from skopaq.execution.safety_checker import SafetyChecker - # Always need an INDstocksClient for LTP polling - token_mgr = TokenManager() - client = INDstocksClient(config, token_mgr) + # MarketDataProvider — handles all LTP/quote fetching for any broker + market_data = MarketDataProvider(config) # Wire live or paper backend from skopaq.broker.paper_engine import PaperEngine - paper = PaperEngine(initial_capital=config.initial_paper_capital) + paper = PaperEngine( + initial_capital=config.initial_paper_capital, + market_data=market_data, + ) live_client = None + broker_client = None + if config.trading_mode == "live": - live_client = client # reuse same client + live_client = _create_live_client(config) + broker_client = live_client + else: + # Paper mode — try to attach broker for real market data + try: + broker_client = _create_live_client(config) + except Exception: + logger.info("No broker token — using yfinance for market data") router = OrderRouter(config, paper, live_client=live_client) - rules = PAPER_SAFETY_RULES if config.trading_mode == "paper" else SAFETY_RULES + + from skopaq.constants import ( + INTRADAY_PAPER_SAFETY_RULES, INTRADAY_SAFETY_RULES, + ) + is_intraday = config.default_product == "MIS" + if is_intraday: + rules = INTRADAY_PAPER_SAFETY_RULES if config.trading_mode == "paper" else INTRADAY_SAFETY_RULES + else: + rules = PAPER_SAFETY_RULES if config.trading_mode == "paper" else SAFETY_RULES + safety = SafetyChecker( rules=rules, max_sector_concentration_pct=config.max_sector_concentration_pct, ) - executor = Executor(router, safety) + executor = Executor(router, safety, product=config.default_product) # Build LLM for sell analyst (if AI enabled) llm = None @@ -630,11 +916,15 @@ def _handle_sigint(*_): loop = asyncio.get_running_loop() loop.add_signal_handler(sig.SIGINT, _handle_sigint) - # Run monitor within client context - async with client: + # Open broker client context and wire into market data provider + if broker_client is not None: + await broker_client.__aenter__() + market_data.set_broker_client(broker_client) + + try: monitor_instance = PositionMonitor( executor=executor, - client=client, + market_data=market_data, router=router, config=config, llm=llm, @@ -642,6 +932,9 @@ def _handle_sigint(*_): ai_enabled=ai_enabled, ) return await monitor_instance.run() + finally: + if broker_client is not None: + await broker_client.__aexit__(None, None, None) # ── Daemon ─────────────────────────────────────────────────────────────────── diff --git a/skopaq/config.py b/skopaq/config.py index e1c3da1..bd2f67b 100644 --- a/skopaq/config.py +++ b/skopaq/config.py @@ -31,15 +31,35 @@ class SkopaqConfig(BaseSettings): upstash_redis_url: str = "" upstash_redis_token: SecretStr = SecretStr("") + # ── Broker Selection ─────────────────────────────────────────────── + broker: Literal["indstocks", "kite"] = "indstocks" + # ── INDstocks Broker ──────────────────────────────────────────────── indstocks_token: SecretStr = SecretStr("") indstocks_base_url: str = "https://api.indstocks.com" indstocks_ws_price_url: str = "wss://ws-prices.indstocks.com/api/v1/ws/prices" indstocks_ws_order_url: str = "wss://ws-order-updates.indstocks.com" + # ── Kite Connect (Zerodha) Broker ────────────────────────────────── + kite_api_key: SecretStr = SecretStr("") + kite_api_secret: SecretStr = SecretStr("") + kite_access_token: SecretStr = SecretStr("") # Set directly or via login flow + + # ── Angel One SmartAPI (free market data) ──────────────────────────── + angelone_api_key: SecretStr = SecretStr("") + angelone_client_id: str = "" + angelone_password: SecretStr = SecretStr("") + angelone_totp_secret: SecretStr = SecretStr("") + + # ── Upstox API (free market data) ────────────────────────────────── + upstox_api_key: SecretStr = SecretStr("") + upstox_api_secret: SecretStr = SecretStr("") + upstox_access_token: SecretStr = SecretStr("") + # ── Trading Mode ──────────────────────────────────────────────────── trading_mode: Literal["paper", "live"] = "paper" initial_paper_capital: float = 1_000_000.0 # INR + default_product: Literal["CNC", "MIS", "NRML"] = "CNC" # CNC=delivery, MIS=intraday # ── LLM API Keys ─────────────────────────────────────────────────── google_api_key: SecretStr = SecretStr("") # Gemini Flash (scanner) diff --git a/skopaq/constants.py b/skopaq/constants.py index e5a9c60..757ca79 100644 --- a/skopaq/constants.py +++ b/skopaq/constants.py @@ -84,6 +84,28 @@ class SafetyRules: ) +# Intraday (MIS) variant — strict market hours + mandatory EOD exit. +# MIS positions get squared off by the broker at 3:15 PM if not closed. +# We use tighter limits since intraday margin provides leverage. +INTRADAY_SAFETY_RULES = SafetyRules( + market_hours_only=True, # Must trade during NSE hours + require_stop_loss=True, # Mandatory for intraday + min_stop_loss_pct=0.01, # 1% min (tighter than delivery 2%) + max_position_pct=0.20, # 20% — MIS gets ~5x margin + max_order_value_inr=500_000.0, # Same cap, but margin makes it go further + max_daily_loss_pct=0.02, # 2% daily loss limit (tighter) + cool_down_after_loss_minutes=10, # Shorter cooldown for intraday +) + +# Intraday paper variant — relaxed timing for testing outside market hours. +INTRADAY_PAPER_SAFETY_RULES = SafetyRules( + market_hours_only=False, # Test anytime + require_stop_loss=False, # Relaxed for paper + max_position_pct=0.20, # Same leverage limits + max_daily_loss_pct=0.02, # Same loss limit +) + + # Daemon variant — tighter limits for fully autonomous unattended trading. DAEMON_SAFETY_RULES = SafetyRules( max_open_positions=3, # 3 vs 5: tighter for unattended diff --git a/skopaq/execution/daemon.py b/skopaq/execution/daemon.py index f258d1a..c6aa205 100644 --- a/skopaq/execution/daemon.py +++ b/skopaq/execution/daemon.py @@ -107,7 +107,8 @@ def __init__( self._scan_delay = config.daemon_scan_delay_after_open_seconds # Built during PRE_OPEN - self._client = None # INDstocksClient + self._client = None # INDstocksClient or KiteConnectClient + self._market_data = None # MarketDataProvider self._router = None # OrderRouter self._executor = None # Executor self._graph = None # SkopaqTradingGraph @@ -249,11 +250,10 @@ async def run_session( async def _phase_pre_open(self) -> None: """Validate token, build LLM map, create executor stack.""" - from skopaq.broker.client import INDstocksClient from skopaq.broker.paper_engine import PaperEngine - from skopaq.broker.token_manager import TokenManager from skopaq.cli.main import ( _build_upstream_config, + _create_live_client, _create_memory_store, ) from skopaq.execution.executor import Executor @@ -265,24 +265,36 @@ async def _phase_pre_open(self) -> None: config = self._config - # 1. Validate INDstocks token - token_mgr = TokenManager() - health = token_mgr.get_health() - if not health.valid: - raise RuntimeError( - f"INDstocks token invalid: {health.warning}. " - "Run `skopaq token set ` first." - ) + # 1. Validate broker token (INDstocks or Kite) + if config.broker == "kite": + from skopaq.broker.kite_token_manager import KiteTokenManager + token_mgr = KiteTokenManager() + health = token_mgr.get_health() + if not health.valid: + raise RuntimeError( + f"Kite token invalid: {health.warning}. " + "Run `skopaq kite session ` first." + ) + else: + from skopaq.broker.token_manager import TokenManager + token_mgr = TokenManager() + health = token_mgr.get_health() + if not health.valid: + raise RuntimeError( + f"INDstocks token invalid: {health.warning}. " + "Run `skopaq token set ` first." + ) logger.info("Token valid — expires in %s", health.remaining) # 2. Open broker client session - self._client = INDstocksClient(config, token_mgr) + self._client = _create_live_client(config) await self._client.__aenter__() # Validate token against API profile = await self._client.get_profile() logger.info( - "Broker session open — user=%s", + "Broker session open — broker=%s user=%s", + config.broker, profile.name or profile.email or "unknown", ) @@ -291,9 +303,17 @@ async def _phase_pre_open(self) -> None: self._llm_map = build_llm_map() logger.info("LLM map built: %d roles", len(self._llm_map)) - # 4. Build executor stack + # 4. Build executor stack with MarketDataProvider + from skopaq.broker.market_data import MarketDataProvider + is_paper = config.trading_mode == "paper" - paper = PaperEngine(initial_capital=config.initial_paper_capital) + self._market_data = MarketDataProvider(config) + self._market_data.set_broker_client(self._client) + + paper = PaperEngine( + initial_capital=config.initial_paper_capital, + market_data=self._market_data, + ) live_client = None if is_paper else self._client self._router = OrderRouter(config, paper, live_client=live_client) @@ -312,7 +332,11 @@ async def _phase_pre_open(self) -> None: atr_period=config.atr_period, ) - self._executor = Executor(self._router, safety, position_sizer=sizer) + self._executor = Executor( + self._router, safety, + position_sizer=sizer, + product=config.default_product, + ) # 5. Build analysis graph upstream_config = _build_upstream_config(config) @@ -408,16 +432,14 @@ async def _phase_analyze_and_trade( candidate.urgency, ) - # For paper mode, inject a real-time quote + # Pre-warm the quote cache so analysis and fills have fresh data. + # MarketDataProvider handles this automatically on execute_order_async(), + # but pre-warming improves the entry_price resolution in the executor. if config.trading_mode == "paper": try: - from skopaq.cli.main import _inject_paper_quote - paper_engine = self._router._paper # noqa: SLF001 - await _inject_paper_quote(config, paper_engine, symbol) + await self._market_data.get_quote(symbol) except Exception: - logger.warning( - "Quote injection failed for %s", symbol, exc_info=True, - ) + logger.debug("Quote pre-warm failed for %s", symbol) try: result = await self._graph.analyze_and_execute( @@ -502,7 +524,7 @@ async def _phase_monitor(self) -> MonitorResult: monitor = PositionMonitor( executor=self._executor, - client=self._client, + market_data=self._market_data, router=self._router, config=self._config, llm=llm, diff --git a/skopaq/execution/executor.py b/skopaq/execution/executor.py index 2cdb955..2defe58 100644 --- a/skopaq/execution/executor.py +++ b/skopaq/execution/executor.py @@ -46,10 +46,15 @@ def __init__( router: OrderRouter, safety: SafetyChecker, position_sizer: Optional[PositionSizer] = None, + product: str = "CNC", ) -> None: self._router = router self._safety = safety self._sizer = position_sizer + # Map common aliases: MIS → INTRADAY, NRML → MARGIN + _product_map = {"MIS": "INTRADAY", "NRML": "MARGIN"} + canonical = _product_map.get(product, product) if product else "CNC" + self._product = Product(canonical) async def execute_signal( self, @@ -243,7 +248,7 @@ def _build_order(self, signal: TradingSignal) -> Optional[OrderRequest]: order_type=order_type, price=signal.entry_price, trigger_price=signal.stop_loss if side == Side.BUY else None, - product=Product.CNC, + product=self._product, tag=f"skopaq-{signal.confidence}", ) diff --git a/skopaq/execution/order_router.py b/skopaq/execution/order_router.py index a859a75..4b76b40 100644 --- a/skopaq/execution/order_router.py +++ b/skopaq/execution/order_router.py @@ -3,14 +3,16 @@ The router is intentionally thin — it checks ``config.trading_mode`` and dispatches to the appropriate execution backend. Switching paper → live is a config change, not a code change. + +Supports multiple brokers (INDstocks, Kite Connect) — the ``live_client`` +parameter accepts either client type. """ from __future__ import annotations import logging -from typing import Optional +from typing import Optional, Protocol, Union -from skopaq.broker.client import INDstocksClient from skopaq.broker.models import ( ExecutionResult, Funds, @@ -21,31 +23,44 @@ TradingSignal, ) from skopaq.broker.paper_engine import PaperEngine -from skopaq.broker.scrip_resolver import resolve_security_id from skopaq.config import SkopaqConfig logger = logging.getLogger(__name__) +class BrokerClient(Protocol): + """Protocol that both INDstocksClient and KiteConnectClient satisfy. + + This allows OrderRouter to accept either broker without tight coupling. + """ + + async def place_order(self, order: OrderRequest) -> OrderResponse: ... + async def get_positions(self) -> list[Position]: ... + async def get_holdings(self) -> list[Holding]: ... + async def get_funds(self) -> Funds: ... + async def get_orders(self) -> list[OrderResponse]: ... + + class OrderRouter: """Routes orders to the correct execution backend. In ``paper`` mode all orders go through the PaperEngine. - In ``live`` mode orders go to the INDstocks REST API. + In ``live`` mode orders go to the configured broker (INDstocks or Kite). Args: config: Application configuration (determines mode). paper_engine: Paper trading engine instance. - live_client: INDstocks REST client (can be None in paper-only mode). + live_client: Broker REST client (INDstocks or Kite; can be None in paper-only mode). """ def __init__( self, config: SkopaqConfig, paper_engine: PaperEngine, - live_client: Optional[INDstocksClient] = None, + live_client: Optional[Union[BrokerClient]] = None, ) -> None: self._mode = config.trading_mode + self._broker = config.broker self._paper = paper_engine self._live = live_client @@ -53,6 +68,10 @@ def __init__( def mode(self) -> str: return self._mode + @property + def broker(self) -> str: + return self._broker + async def execute( self, order: OrderRequest, @@ -61,14 +80,20 @@ async def execute( """Route an order to the appropriate backend.""" if self._mode == "live": return await self._execute_live(order, signal) - return self._execute_paper(order, signal) + return await self._execute_paper(order, signal) - def _execute_paper( + async def _execute_paper( self, order: OrderRequest, signal: Optional[TradingSignal], ) -> ExecutionResult: - """Execute via paper engine (synchronous).""" + """Execute via paper engine. + + Uses async execution (with auto-refresh) when a MarketDataProvider + is attached, otherwise falls back to synchronous execution. + """ + if self._paper._market_data is not None: + return await self._paper.execute_order_async(order, signal) return self._paper.execute_order(order, signal) async def _execute_live( @@ -76,18 +101,20 @@ async def _execute_live( order: OrderRequest, signal: Optional[TradingSignal], ) -> ExecutionResult: - """Execute via live INDstocks API. + """Execute via live broker API (INDstocks or Kite Connect). - Resolves ``security_id`` from the instruments CSV if not already - set on the order, then places the order via the broker client. + For INDstocks: resolves ``security_id`` from instruments CSV. + For Kite: uses ``tradingsymbol`` directly (no extra resolution needed). """ if self._live is None: logger.error("Live client not configured — falling back to paper") - return self._execute_paper(order, signal) + return await self._execute_paper(order, signal) try: - # Resolve security_id if missing (executor builds orders without it) - if not order.security_id: + # INDstocks requires security_id resolution before placing + if self._broker == "indstocks" and not order.security_id: + from skopaq.broker.scrip_resolver import resolve_security_id + order.security_id = await resolve_security_id( self._live, order.symbol, order.exchange.value, ) @@ -97,13 +124,17 @@ async def _execute_live( ) response = await self._live.place_order(order) + + # Kite brokerage: ₹20 per executed order or 0.03%, whichever is lower + brokerage = 20.0 if self._broker == "kite" else 20.0 + return ExecutionResult( success=True, order=response, signal=signal, mode="live", fill_price=order.price, # Limit price (actual fill via order book) - brokerage=20.0, # INDstocks flat fee estimate + brokerage=brokerage, ) except Exception as exc: logger.error("Live order failed: %s — NOT falling back to paper", exc) diff --git a/skopaq/execution/position_monitor.py b/skopaq/execution/position_monitor.py index b76ac1b..a05b280 100644 --- a/skopaq/execution/position_monitor.py +++ b/skopaq/execution/position_monitor.py @@ -3,9 +3,12 @@ Safety tier (every poll): hard stop-loss, trailing stop, EOD exit. AI tier (every N polls): Gemini 3 Flash sell analyst for intelligent exits. +Uses ``MarketDataProvider`` for LTP polling — works with any broker +(INDstocks, Kite Connect) or falls back to yfinance in paper mode. + Usage:: - monitor = PositionMonitor(executor, client, router, config, llm, stop_event) + monitor = PositionMonitor(executor, market_data, router, config, llm, stop_event) result = await monitor.run() """ @@ -22,7 +25,7 @@ from skopaq.broker.models import TradingSignal if TYPE_CHECKING: - from skopaq.broker.client import INDstocksClient + from skopaq.broker.market_data import MarketDataProvider from skopaq.config import SkopaqConfig from skopaq.execution.executor import Executor from skopaq.execution.order_router import OrderRouter @@ -39,7 +42,6 @@ class MonitoredPosition: """Per-position tracking state.""" symbol: str - scrip_code: str entry_price: float quantity: int high_water_mark: float = 0.0 # For trailing stop @@ -80,15 +82,17 @@ class PositionMonitor: def __init__( self, executor: Executor, - client: INDstocksClient, + market_data: MarketDataProvider, router: OrderRouter, config: SkopaqConfig, llm=None, stop_event: Optional[asyncio.Event] = None, ai_enabled: bool = True, + # Backward compat: accept `client` kwarg and ignore it + client=None, ): self._executor = executor - self._client = client + self._market_data = market_data self._router = router self._config = config self._llm = llm @@ -106,7 +110,8 @@ def __init__( # Minimum profit gate — prevents selling for tiny gains eaten by brokerage self._min_profit_pct = config.daemon_min_profit_threshold_pct self._min_profit_inr = config.daemon_min_profit_threshold_inr - self._est_brokerage = 120.0 # ~₹60 per side for INDstocks (brokerage + GST) + # Estimate round-trip brokerage (varies by broker, conservative default) + self._est_brokerage = 120.0 # ~₹60 per side (brokerage + GST) async def run(self) -> MonitorResult: """Main monitoring loop. Returns when all positions are closed, @@ -131,9 +136,9 @@ async def run(self) -> MonitorResult: result.cycles = cycle for pos in list(positions): # copy — may mutate - # Fetch current price + # Fetch current price via MarketDataProvider try: - ltp = await self._client.get_ltp(pos.scrip_code) + ltp = await self._market_data.get_ltp(pos.symbol) except Exception: logger.warning( "LTP fetch failed for %s — skipping cycle", @@ -205,7 +210,7 @@ async def run(self) -> MonitorResult: if positions and self._should_eod_exit(): for pos in list(positions): try: - ltp = await self._client.get_ltp(pos.scrip_code) + ltp = await self._market_data.get_ltp(pos.symbol) except Exception: ltp = 0 if ltp > 0: @@ -216,27 +221,19 @@ async def run(self) -> MonitorResult: # ── Discovery ──────────────────────────────────────────────────────── async def _discover_positions(self) -> list[MonitoredPosition]: - """Fetch open positions and resolve scrip codes.""" - from skopaq.broker.scrip_resolver import resolve_scrip_code + """Fetch open positions from the router. + No scrip code resolution needed — the MarketDataProvider handles + symbol-to-instrument mapping internally for each broker. + """ raw_positions = await self._router.get_positions() monitored = [] for pos in raw_positions: if pos.quantity <= 0: continue - try: - scrip_code = await resolve_scrip_code(self._client, pos.symbol) - except Exception: - logger.warning( - "Could not resolve scrip for %s — skipping", - pos.symbol, exc_info=True, - ) - continue - monitored.append(MonitoredPosition( symbol=pos.symbol, - scrip_code=scrip_code, entry_price=pos.average_price, quantity=int(pos.quantity), )) @@ -338,16 +335,13 @@ async def _execute_sell( ) try: - # Inject quote for paper mode + # Inject fresh quote for paper mode so the fill simulation + # uses the latest LTP with realistic bid/ask from provider if self._config.trading_mode == "paper": - from skopaq.broker.models import Quote - paper_engine = self._router._paper # noqa: SLF001 - paper_engine.update_quote(Quote( - symbol=pos.symbol, - ltp=ltp, - bid=ltp * 0.999, - ask=ltp * 1.001, - )) + quote = await self._market_data.get_quote(pos.symbol) + if quote.ltp > 0: + paper_engine = self._router._paper # noqa: SLF001 + paper_engine.update_quote(quote) exec_result = await self._executor.execute_signal(signal) diff --git a/tests/unit/broker/test_angelone_client.py b/tests/unit/broker/test_angelone_client.py new file mode 100644 index 0000000..d41787d --- /dev/null +++ b/tests/unit/broker/test_angelone_client.py @@ -0,0 +1,238 @@ +"""Tests for Angel One SmartAPI client.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from skopaq.broker.angelone_client import AngelOneClient, AngelOneError + + +@pytest.fixture +def client(): + return AngelOneClient( + api_key="test_api_key", + client_id="TEST123", + password="test_pass", + totp_secret="", + ) + + +def _mock_login_response(): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "status": True, + "data": {"jwtToken": "jwt_token_123", "refreshToken": "refresh_123"}, + } + return resp + + +class TestAngelOneInit: + def test_creates_with_credentials(self, client): + assert client._api_key == "test_api_key" + assert client._client_id == "TEST123" + assert client._client is None + + def test_headers_include_api_key(self, client): + client._jwt_token = "test_jwt" + headers = client._headers() + assert headers["Authorization"] == "Bearer test_jwt" + assert headers["X-PrivateKey"] == "test_api_key" + + +@pytest.mark.asyncio +class TestAngelOneLogin: + async def test_login_stores_jwt(self, client): + """Login should store JWT and refresh tokens.""" + # Manually create the httpx client (skip __aenter__ auto-login) + import httpx + client._client = httpx.AsyncClient( + base_url="https://apiconnect.angelone.in", timeout=5, + ) + try: + client._client.request = AsyncMock(return_value=_mock_login_response()) + await client._login() + + assert client._jwt_token == "jwt_token_123" + assert client._refresh_token == "refresh_123" + finally: + await client._client.aclose() + client._client = None + + async def test_login_failure_raises(self, client): + import httpx + client._client = httpx.AsyncClient( + base_url="https://apiconnect.angelone.in", timeout=5, + ) + try: + fail_resp = MagicMock() + fail_resp.status_code = 200 + fail_resp.json.return_value = { + "status": False, + "message": "Invalid credentials", + } + client._client.request = AsyncMock(return_value=fail_resp) + + with pytest.raises(AngelOneError, match="Invalid credentials"): + await client._login() + finally: + await client._client.aclose() + client._client = None + + async def test_login_no_jwt_raises(self, client): + import httpx + client._client = httpx.AsyncClient( + base_url="https://apiconnect.angelone.in", timeout=5, + ) + try: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "status": True, + "data": {}, # No jwtToken + } + client._client.request = AsyncMock(return_value=resp) + + with pytest.raises(AngelOneError, match="no JWT token"): + await client._login() + finally: + await client._client.aclose() + client._client = None + + +@pytest.mark.asyncio +class TestAngelOneQuote: + async def _setup_client(self, client): + """Helper: create httpx client and mock login.""" + import httpx + client._client = httpx.AsyncClient( + base_url="https://apiconnect.angelone.in", timeout=5, + ) + client._jwt_token = "test_jwt" + + async def test_get_quote_parses_response(self, client): + await self._setup_client(client) + try: + search_resp = MagicMock() + search_resp.status_code = 200 + search_resp.json.return_value = { + "status": True, + "data": [{"tradingsymbol": "RELIANCE", "symboltoken": "2885"}], + } + + quote_resp = MagicMock() + quote_resp.status_code = 200 + quote_resp.json.return_value = { + "status": True, + "data": { + "fetched": [{ + "ltp": 2500.0, + "open": 2490.0, + "high": 2510.0, + "low": 2480.0, + "close": 2470.0, + "tradeVolume": 1000000, + "netChange": 30.0, + "percentChange": 1.21, + "depth": { + "buy": [{"price": 2499.5, "quantity": 100}], + "sell": [{"price": 2500.5, "quantity": 50}], + }, + }], + }, + } + + client._client.request = AsyncMock(side_effect=[search_resp, quote_resp]) + + quote = await client.get_quote("RELIANCE") + assert quote.symbol == "RELIANCE" + assert quote.ltp == 2500.0 + assert quote.bid == 2499.5 + assert quote.ask == 2500.5 + assert quote.volume == 1000000 + finally: + await client._client.aclose() + client._client = None + + async def test_get_ltp(self, client): + await self._setup_client(client) + try: + search_resp = MagicMock() + search_resp.status_code = 200 + search_resp.json.return_value = { + "status": True, + "data": [{"tradingsymbol": "TCS", "symboltoken": "11536"}], + } + + ltp_resp = MagicMock() + ltp_resp.status_code = 200 + ltp_resp.json.return_value = { + "status": True, + "data": {"fetched": [{"ltp": 3500.0}]}, + } + + client._client.request = AsyncMock(side_effect=[search_resp, ltp_resp]) + ltp = await client.get_ltp("TCS") + assert ltp == 3500.0 + finally: + await client._client.aclose() + client._client = None + + async def test_empty_fetched_returns_default(self, client): + await self._setup_client(client) + try: + search_resp = MagicMock() + search_resp.status_code = 200 + search_resp.json.return_value = { + "status": True, + "data": [{"tradingsymbol": "X", "symboltoken": "999"}], + } + + empty_resp = MagicMock() + empty_resp.status_code = 200 + empty_resp.json.return_value = { + "status": True, + "data": {"fetched": []}, + } + + client._client.request = AsyncMock(side_effect=[search_resp, empty_resp]) + quote = await client.get_quote("X") + assert quote.ltp == 0.0 + finally: + await client._client.aclose() + client._client = None + + +@pytest.mark.asyncio +class TestAngelOneRequest: + async def test_http_error_raises(self, client): + import httpx + client._client = httpx.AsyncClient( + base_url="https://apiconnect.angelone.in", timeout=5, + ) + client._jwt_token = "jwt" + try: + resp = MagicMock() + resp.status_code = 500 + resp.text = "Internal Server Error" + client._client.request = AsyncMock(return_value=resp) + + with pytest.raises(AngelOneError, match="API error 500"): + await client._request("POST", "/test") + finally: + await client._client.aclose() + client._client = None + + +class TestSymbolCache: + def test_cache_persists(self): + AngelOneClient._symbol_cache["NSE:TEST"] = "12345" + assert AngelOneClient._symbol_cache.get("NSE:TEST") == "12345" + del AngelOneClient._symbol_cache["NSE:TEST"] + + +class TestAngelOneError: + def test_fields(self): + err = AngelOneError("Test", status_code=400) + assert str(err) == "Test" + assert err.status_code == 400 diff --git a/tests/unit/broker/test_indstocks_client.py b/tests/unit/broker/test_indstocks_client.py new file mode 100644 index 0000000..87f1469 --- /dev/null +++ b/tests/unit/broker/test_indstocks_client.py @@ -0,0 +1,386 @@ +"""Tests for INDstocksClient — async REST client for INDstocks broker API. + +All network calls are mocked via httpx mock responses. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from skopaq.broker.client import BrokerError, INDstocksClient +from skopaq.broker.models import ( + CancelOrderRequest, + Funds, + Holding, + ModifyOrderRequest, + OrderRequest, + OrderResponse, + Position, + Quote, + Side, + UserProfile, +) + + +@pytest.fixture +def mock_config(): + config = MagicMock() + config.indstocks_base_url = "https://api.indstocks.com" + return config + + +@pytest.fixture +def mock_token_mgr(): + mgr = MagicMock() + mgr.get_token.return_value = "test-token-123" + return mgr + + +@pytest.fixture +def client(mock_config, mock_token_mgr): + return INDstocksClient(mock_config, mock_token_mgr) + + +# ── Init & Auth ────────────────────────────────────────────────────────────── + + +class TestInit: + def test_creates_with_config(self, client): + assert client._base_url == "https://api.indstocks.com" + assert client._client is None + + def test_headers_no_bearer_prefix(self, client): + headers = client._headers() + assert headers["Authorization"] == "test-token-123" + assert "Bearer" not in headers["Authorization"] + + def test_headers_raises_on_expired_token(self, mock_config): + from skopaq.broker.token_manager import TokenExpiredError + + mgr = MagicMock() + mgr.get_token.side_effect = TokenExpiredError("Token expired") + c = INDstocksClient(mock_config, mgr) + with pytest.raises(BrokerError): + c._headers() + + +@pytest.mark.asyncio +class TestContextManager: + async def test_aenter_creates_httpx_client(self, client): + async with client as c: + assert c._client is not None + assert client._client is None + + async def test_aexit_closes_client(self, client): + async with client: + pass + assert client._client is None + + +# ── _request ───────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestRequest: + async def test_request_without_init_raises(self, client): + with pytest.raises(BrokerError, match="not initialised"): + await client._request("GET", "/test") + + async def test_request_unwraps_data_key(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "ok", "data": {"key": "val"}} + client._client.request = AsyncMock(return_value=mock_resp) + + result = await client._request("GET", "/test") + assert result == {"key": "val"} + + async def test_request_passes_through_no_data_key(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [1, 2, 3] + client._client.request = AsyncMock(return_value=mock_resp) + + result = await client._request("GET", "/test") + assert result == [1, 2, 3] + + async def test_request_raises_on_4xx(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + client._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(BrokerError, match="API error 403"): + await client._request("GET", "/test") + + +# ── Market Data ────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestMarketData: + async def test_get_quote(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": { + "NSE_2885": { + "live_price": 2500.5, + "day_open": 2490.0, + "day_high": 2510.0, + "day_low": 2480.0, + "prev_close": 2470.0, + "volume": 1000000, + "day_change": 30.5, + "day_change_percentage": 1.23, + "best_bid_price": 2500.0, + "best_ask_price": 2501.0, + } + } + } + client._client.request = AsyncMock(return_value=mock_resp) + + quote = await client.get_quote("NSE_2885", symbol="RELIANCE") + assert quote.symbol == "RELIANCE" + assert quote.ltp == 2500.5 + assert quote.open == 2490.0 + assert quote.high == 2510.0 + assert quote.low == 2480.0 + assert quote.close == 2470.0 + assert quote.volume == 1000000 + assert quote.bid == 2500.0 + assert quote.ask == 2501.0 + + async def test_get_ltp(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": {"NSE_2885": {"live_price": 2500.0}} + } + client._client.request = AsyncMock(return_value=mock_resp) + + ltp = await client.get_ltp("NSE_2885") + assert ltp == 2500.0 + + async def test_get_quotes_batch(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": { + "NSE_2885": {"live_price": 2500.0, "day_open": 2490}, + "NSE_11536": {"live_price": 3500.0, "day_open": 3490}, + } + } + client._client.request = AsyncMock(return_value=mock_resp) + + quotes = await client.get_quotes( + ["NSE_2885", "NSE_11536"], + symbols=["RELIANCE", "TCS"], + ) + assert len(quotes) == 2 + assert quotes[0].symbol == "RELIANCE" + assert quotes[1].symbol == "TCS" + + async def test_get_historical(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": { + "NSE_2885": { + "candles": [ + {"ts": 1740960000, "o": 2490, "h": 2510, "l": 2480, "c": 2500, "v": 500000}, + ] + } + } + } + client._client.request = AsyncMock(return_value=mock_resp) + + candles = await client.get_historical("NSE_2885", interval="1day") + assert len(candles) == 1 + assert candles[0].open == 2490 + assert candles[0].close == 2500 + assert candles[0].volume == 500000 + + +# ── Orders ─────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestOrders: + async def test_place_order(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": {"order_id": "ORD123", "status": "PENDING", "message": "OK"} + } + client._client.request = AsyncMock(return_value=mock_resp) + + from decimal import Decimal + order = OrderRequest( + symbol="RELIANCE", side=Side.BUY, quantity=Decimal("10"), + price=2500.0, security_id="2885", + ) + result = await client.place_order(order) + assert result.order_id == "ORD123" + assert result.status == "PENDING" + + # Verify payload was sent correctly + call_args = client._client.request.call_args + assert call_args.kwargs["json"]["txn_type"] == "BUY" + assert call_args.kwargs["json"]["qty"] == 10 + assert call_args.kwargs["json"]["security_id"] == "2885" + assert call_args.kwargs["json"]["algo_id"] == "99999" + + async def test_cancel_order(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": {"order_id": "ORD123", "status": "CANCELLED"} + } + client._client.request = AsyncMock(return_value=mock_resp) + + req = CancelOrderRequest(order_id="ORD123") + result = await client.cancel_order(req) + assert result.status == "CANCELLED" + + async def test_get_orders(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": [ + {"order_id": "ORD1", "status": "COMPLETE", "message": "Filled"}, + {"order_id": "ORD2", "status": "PENDING", "message": "Open"}, + ] + } + client._client.request = AsyncMock(return_value=mock_resp) + + orders = await client.get_orders() + assert len(orders) == 2 + assert orders[0].order_id == "ORD1" + assert orders[0].status == "COMPLETE" + assert isinstance(orders[0], OrderResponse) + + async def test_get_order_book(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": [{"order_id": "ORD1"}] + } + client._client.request = AsyncMock(return_value=mock_resp) + + book = await client.get_order_book() + assert len(book) == 1 + + +# ── Portfolio ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestPortfolio: + async def test_get_positions(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": [ + { + "symbol": "RELIANCE", "exchange": "NSE", + "net_qty": 10, "avg_price": 2450.0, + "last_price": 2500.0, "realized_profit": 500.0, + } + ] + } + client._client.request = AsyncMock(return_value=mock_resp) + + positions = await client.get_positions() + assert len(positions) == 1 + assert positions[0].symbol == "RELIANCE" + assert positions[0].quantity == 10 + assert positions[0].average_price == 2450.0 + + async def test_get_holdings(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": [ + {"symbol": "TCS", "exchange": "NSE", "quantity": 5, "average_price": 3500.0} + ] + } + client._client.request = AsyncMock(return_value=mock_resp) + + holdings = await client.get_holdings() + assert len(holdings) == 1 + assert holdings[0].symbol == "TCS" + + async def test_get_funds(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": { + "detailed_avl_balance": {"eq_cnc": 500000.0}, + "pledge_received": 100000.0, + } + } + client._client.request = AsyncMock(return_value=mock_resp) + + funds = await client.get_funds() + assert funds.available_cash == 500000.0 + assert funds.total_collateral == 600000.0 + + async def test_get_profile(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "data": { + "user_id": "USR123", + "name": "Test User", + "email": "test@example.com", + } + } + client._client.request = AsyncMock(return_value=mock_resp) + + profile = await client.get_profile() + assert profile.user_id == "USR123" + assert profile.name == "Test User" + assert profile.broker == "INDstocks" + + +# ── Error Handling ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestErrors: + async def test_broker_error_fields(self): + err = BrokerError("Test error", status_code=400, body='{"msg": "bad"}') + assert str(err) == "Test error" + assert err.status_code == 400 + assert err.body == '{"msg": "bad"}' + + async def test_empty_response_returns_defaults(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"data": {}} + client._client.request = AsyncMock(return_value=mock_resp) + + funds = await client.get_funds() + assert funds.available_cash == 0.0 + + positions = await client.get_positions() + assert positions == [] diff --git a/tests/unit/broker/test_kite_client.py b/tests/unit/broker/test_kite_client.py new file mode 100644 index 0000000..49113c0 --- /dev/null +++ b/tests/unit/broker/test_kite_client.py @@ -0,0 +1,447 @@ +"""Tests for KiteConnectClient.""" + +import json +from datetime import datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from skopaq.broker.kite_client import ( + KITE_PRODUCT_CNC, + KITE_PRODUCT_MIS, + KITE_VARIETY_AMO, + KITE_VARIETY_REGULAR, + KiteBrokerError, + KiteConnectClient, + _PRODUCT_MAP, +) +from skopaq.broker.models import ( + Exchange, + Funds, + Holding, + OrderRequest, + OrderResponse, + OrderType, + Position, + Product, + Quote, + Side, + UserProfile, +) + + +@pytest.fixture +def mock_config(): + """Minimal config mock for KiteConnectClient.""" + config = MagicMock() + config.kite_api_key = MagicMock() + config.kite_api_key.get_secret_value.return_value = "test_api_key" + config.broker = "kite" + return config + + +@pytest.fixture +def mock_token_mgr(): + mgr = MagicMock() + mgr.get_token.return_value = "test_access_token" + return mgr + + +@pytest.fixture +def client(mock_config, mock_token_mgr): + return KiteConnectClient(mock_config, mock_token_mgr) + + +class TestKiteConnectClientInit: + def test_creates_with_config(self, mock_config, mock_token_mgr): + c = KiteConnectClient(mock_config, mock_token_mgr) + assert c._api_key == "test_api_key" + assert c._client is None + + def test_headers(self, client): + headers = client._headers() + assert headers["Authorization"] == "token test_api_key:test_access_token" + assert headers["X-Kite-Version"] == "3" + + +class TestNormalizeInstrumentKey: + def test_plain_symbol(self): + assert KiteConnectClient._normalize_instrument_key("RELIANCE") == "NSE:RELIANCE" + + def test_already_prefixed(self): + assert KiteConnectClient._normalize_instrument_key("NSE:RELIANCE") == "NSE:RELIANCE" + + def test_bse_symbol(self): + assert KiteConnectClient._normalize_instrument_key("BSE:RELIANCE") == "BSE:RELIANCE" + + +class TestMapInterval: + def test_1day(self): + assert KiteConnectClient._map_interval("1day") == "day" + + def test_1minute(self): + assert KiteConnectClient._map_interval("1minute") == "minute" + + def test_5minute_passthrough(self): + assert KiteConnectClient._map_interval("5minute") == "5minute" + + def test_unknown_passthrough(self): + assert KiteConnectClient._map_interval("3minute") == "3minute" + + +class TestParseQuote: + def test_parses_kite_quote(self): + data = { + "last_price": 2500.50, + "ohlc": {"open": 2490.0, "high": 2510.0, "low": 2480.0, "close": 2470.0}, + "volume": 1000000, + "net_change": 30.5, + "change": 1.23, + "depth": { + "buy": [{"price": 2500.0, "quantity": 100}], + "sell": [{"price": 2501.0, "quantity": 50}], + }, + } + quote = KiteConnectClient._parse_quote(data, "RELIANCE", "NSE:RELIANCE") + assert quote.symbol == "RELIANCE" + assert quote.exchange == "NSE" + assert quote.ltp == 2500.50 + assert quote.open == 2490.0 + assert quote.high == 2510.0 + assert quote.low == 2480.0 + assert quote.close == 2470.0 + assert quote.volume == 1000000 + assert quote.change == 30.5 + assert quote.change_pct == 1.23 + assert quote.bid == 2500.0 + assert quote.ask == 2501.0 + + def test_empty_data(self): + quote = KiteConnectClient._parse_quote({}, "RELIANCE", "NSE:RELIANCE") + assert quote.symbol == "RELIANCE" + assert quote.ltp == 0.0 + + def test_non_dict(self): + quote = KiteConnectClient._parse_quote("invalid", "RELIANCE", "NSE:RELIANCE") + assert quote.symbol == "RELIANCE" + + +class TestProductMap: + def test_cnc_maps(self): + assert _PRODUCT_MAP["CNC"] == KITE_PRODUCT_CNC + + def test_intraday_maps_to_mis(self): + assert _PRODUCT_MAP["INTRADAY"] == KITE_PRODUCT_MIS + + def test_mis_maps_to_mis(self): + assert _PRODUCT_MAP["MIS"] == KITE_PRODUCT_MIS + + +@pytest.mark.asyncio +class TestKiteClientContextManager: + async def test_aenter_creates_httpx_client(self, client): + async with client as c: + assert c._client is not None + assert client._client is None + + async def test_aexit_closes_client(self, client): + async with client: + pass + assert client._client is None + + +@pytest.mark.asyncio +class TestKiteClientRequest: + async def test_request_without_init_raises(self, client): + with pytest.raises(KiteBrokerError, match="not initialised"): + await client._request("GET", "/test") + + async def test_request_success(self, client): + async with client: + # Mock the httpx client response + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "success", "data": {"key": "value"}} + client._client.request = AsyncMock(return_value=mock_resp) + + result = await client._request("GET", "/test") + assert result == {"key": "value"} + + async def test_request_api_error(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "error", + "message": "Invalid token", + } + client._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(KiteBrokerError, match="Invalid token"): + await client._request("GET", "/test") + + async def test_request_http_error(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + client._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(KiteBrokerError, match="API error 403"): + await client._request("GET", "/test") + + +@pytest.mark.asyncio +class TestKiteClientQuote: + async def test_get_quote(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "NSE:RELIANCE": { + "last_price": 2500.0, + "ohlc": {"open": 2490, "high": 2510, "low": 2480, "close": 2470}, + "volume": 500000, + "net_change": 10.0, + "change": 0.4, + "depth": {"buy": [{"price": 2500}], "sell": [{"price": 2501}]}, + } + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + quote = await client.get_quote("NSE:RELIANCE", symbol="RELIANCE") + assert quote.symbol == "RELIANCE" + assert quote.ltp == 2500.0 + + async def test_get_ltp(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": {"NSE:RELIANCE": {"last_price": 2500.0}}, + } + client._client.request = AsyncMock(return_value=mock_resp) + + ltp = await client.get_ltp("RELIANCE") + assert ltp == 2500.0 + + +@pytest.mark.asyncio +class TestKiteClientOrders: + async def test_place_order(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": {"order_id": "220303000001234"}, + } + client._client.request = AsyncMock(return_value=mock_resp) + + order = OrderRequest( + symbol="RELIANCE", + side=Side.BUY, + quantity=Decimal("10"), + order_type=OrderType.LIMIT, + price=2500.0, + product=Product.CNC, + ) + result = await client.place_order(order) + assert result.order_id == "220303000001234" + assert result.status == "PENDING" + + # Verify the request was made to correct endpoint + call_args = client._client.request.call_args + assert "/orders/regular" in call_args.args[1] + + async def test_place_amo_order(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": {"order_id": "220303000001235"}, + } + client._client.request = AsyncMock(return_value=mock_resp) + + order = OrderRequest( + symbol="RELIANCE", + side=Side.BUY, + quantity=Decimal("10"), + order_type=OrderType.LIMIT, + price=2500.0, + is_amo=True, + ) + result = await client.place_order(order) + assert result.order_id == "220303000001235" + + # AMO orders use /orders/amo + call_args = client._client.request.call_args + assert "/orders/amo" in str(call_args) + + async def test_cancel_order(self, client): + async with client: + from skopaq.broker.models import CancelOrderRequest + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": {"order_id": "220303000001234"}, + } + client._client.request = AsyncMock(return_value=mock_resp) + + req = CancelOrderRequest(order_id="220303000001234") + result = await client.cancel_order(req) + assert result.status == "CANCELLED" + + +@pytest.mark.asyncio +class TestKiteClientPortfolio: + async def test_get_positions(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "net": [ + { + "tradingsymbol": "RELIANCE", + "exchange": "NSE", + "product": "CNC", + "quantity": 10, + "average_price": 2450.0, + "last_price": 2500.0, + "pnl": 500.0, + "day_m2m": 100.0, + "buy_quantity": 10, + "sell_quantity": 0, + "buy_value": 24500.0, + "sell_value": 0.0, + } + ], + "day": [], + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + positions = await client.get_positions() + assert len(positions) == 1 + assert positions[0].symbol == "RELIANCE" + assert positions[0].quantity == 10 + assert positions[0].average_price == 2450.0 + assert positions[0].pnl == 500.0 + + async def test_get_holdings(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": [ + { + "tradingsymbol": "TCS", + "exchange": "NSE", + "quantity": 5, + "average_price": 3500.0, + "last_price": 3600.0, + "day_change": 50.0, + "day_change_percentage": 1.4, + } + ], + } + client._client.request = AsyncMock(return_value=mock_resp) + + holdings = await client.get_holdings() + assert len(holdings) == 1 + assert holdings[0].symbol == "TCS" + assert holdings[0].quantity == 5 + assert holdings[0].pnl == 500.0 # (3600-3500)*5 + + async def test_get_funds(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "equity": { + "available": { + "live_balance": 500000.0, + "collateral": 100000.0, + }, + "utilised": {"debits": 50000.0}, + } + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + funds = await client.get_funds() + assert funds.available_cash == 500000.0 + assert funds.used_margin == 50000.0 + assert funds.total_collateral == 600000.0 + + async def test_get_profile(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "user_id": "AB1234", + "user_name": "Test User", + "email": "test@example.com", + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + profile = await client.get_profile() + assert profile.user_id == "AB1234" + assert profile.name == "Test User" + assert profile.broker == "Kite" + + +@pytest.mark.asyncio +class TestKiteClientHistorical: + async def test_get_historical(self, client): + async with client: + # First mock: instruments CSV for token resolution + instruments_resp = MagicMock() + instruments_resp.status_code = 200 + instruments_resp.text = ( + "instrument_token,exchange_token,tradingsymbol,name,last_price," + "expiry,strike,tick_size,lot_size,instrument_type,segment,exchange\n" + "738561,2885,RELIANCE,RELIANCE INDUSTRIES,2500.0," + ",,0.05,1,EQ,NSE,NSE\n" + ) + + # Second mock: historical data + historical_resp = MagicMock() + historical_resp.status_code = 200 + historical_resp.json.return_value = { + "status": "success", + "data": { + "candles": [ + ["2024-01-15T09:15:00+0530", 2490, 2510, 2480, 2500, 500000], + ["2024-01-16T09:15:00+0530", 2500, 2520, 2490, 2510, 600000], + ] + }, + } + + # Mock both calls in sequence + client._client.request = AsyncMock( + side_effect=[instruments_resp, historical_resp] + ) + + candles = await client.get_historical("NSE:RELIANCE", interval="1day") + assert len(candles) == 2 + assert candles[0].open == 2490 + assert candles[0].close == 2500 + assert candles[0].volume == 500000 diff --git a/tests/unit/broker/test_kite_token_manager.py b/tests/unit/broker/test_kite_token_manager.py new file mode 100644 index 0000000..0ce1271 --- /dev/null +++ b/tests/unit/broker/test_kite_token_manager.py @@ -0,0 +1,177 @@ +"""Tests for Kite Connect token manager.""" + +from datetime import datetime, time, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from skopaq.broker.kite_token_manager import ( + KITE_SESSION_EXPIRY_TIME, + KiteTokenExpiredError, + KiteTokenHealth, + KiteTokenManager, +) + + +@pytest.fixture +def tmp_kite_dir(tmp_path): + """Redirect Kite token storage to a temp directory.""" + token_dir = tmp_path / ".skopaq" + with ( + patch("skopaq.broker.kite_token_manager.KITE_TOKEN_DIR", token_dir), + patch("skopaq.broker.kite_token_manager.KITE_TOKEN_FILE", token_dir / "kite_token.enc"), + patch("skopaq.broker.kite_token_manager.KITE_KEY_FILE", token_dir / "kite_token.key"), + ): + yield token_dir + + +@pytest.fixture +def mgr(tmp_kite_dir): + return KiteTokenManager() + + +class TestKiteTokenManager: + def test_no_token_stored(self, mgr): + health = mgr.get_health() + assert not health.valid + assert "No Kite token stored" in health.warning + + def test_set_and_get_token(self, mgr): + mgr.set_token("kite-access-token-123") + health = mgr.get_health() + assert health.valid + assert health.access_token == "kite-access-token-123" + assert health.remaining.total_seconds() > 0 + + def test_get_token_returns_string(self, mgr): + mgr.set_token("kite-xyz") + assert mgr.get_token() == "kite-xyz" + + def test_get_token_raises_when_no_token(self, mgr): + with pytest.raises(KiteTokenExpiredError): + mgr.get_token() + + def test_clear_token(self, mgr): + mgr.set_token("to-be-cleared") + mgr.clear() + health = mgr.get_health() + assert not health.valid + + def test_encryption_persists(self, mgr, tmp_kite_dir): + mgr.set_token("persistent-kite-token") + # Create a new manager (simulates restart) + mgr2 = KiteTokenManager() + assert mgr2.get_token() == "persistent-kite-token" + + def test_expiry_is_next_6am_ist(self, mgr): + mgr.set_token("test-token") + health = mgr.get_health() + assert health.valid + assert health.expires_at is not None + # Expiry should be at 6:00 AM IST (00:30 UTC) + ist = timezone(timedelta(hours=5, minutes=30)) + expiry_ist = health.expires_at.astimezone(ist) + assert expiry_ist.hour == 6 + assert expiry_ist.minute == 0 + + +class TestKiteTokenManagerNextExpiry: + def test_before_6am_expires_today(self): + """If it's 3 AM IST, token should expire at 6 AM today.""" + ist = timezone(timedelta(hours=5, minutes=30)) + fake_now = datetime(2024, 3, 15, 3, 0, tzinfo=ist) + + with patch("skopaq.broker.kite_token_manager.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.combine = datetime.combine + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + expiry = KiteTokenManager._next_expiry() + + expected = datetime(2024, 3, 15, 6, 0, tzinfo=ist).astimezone(timezone.utc) + assert expiry == expected + + def test_after_6am_expires_tomorrow(self): + """If it's 10 AM IST, token should expire at 6 AM tomorrow.""" + ist = timezone(timedelta(hours=5, minutes=30)) + fake_now = datetime(2024, 3, 15, 10, 0, tzinfo=ist) + + with patch("skopaq.broker.kite_token_manager.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + mock_dt.combine = datetime.combine + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + expiry = KiteTokenManager._next_expiry() + + expected = datetime(2024, 3, 16, 6, 0, tzinfo=ist).astimezone(timezone.utc) + assert expiry == expected + + +class TestKiteTokenManagerLoginUrl: + def test_login_url_format(self): + url = KiteTokenManager.get_login_url("my_api_key") + assert url == "https://kite.zerodha.com/connect/login?v=3&api_key=my_api_key" + + +@pytest.mark.asyncio +class TestKiteGenerateSession: + async def test_generate_session_success(self, mgr): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": "success", + "data": { + "access_token": "generated-access-token", + "user_id": "AB1234", + }, + } + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("skopaq.broker.kite_token_manager.httpx.AsyncClient", return_value=mock_client): + token = await mgr.generate_session("api_key", "api_secret", "request_token") + + assert token == "generated-access-token" + assert mgr.get_token() == "generated-access-token" + + async def test_generate_session_failure(self, mgr): + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Invalid request token" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("skopaq.broker.kite_token_manager.httpx.AsyncClient", return_value=mock_client): + with pytest.raises(KiteTokenExpiredError, match="Session generation failed"): + await mgr.generate_session("api_key", "api_secret", "bad_token") + + async def test_generate_session_api_error(self, mgr): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": "error", + "message": "Invalid checksum", + } + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("skopaq.broker.kite_token_manager.httpx.AsyncClient", return_value=mock_client): + with pytest.raises(KiteTokenExpiredError, match="Invalid checksum"): + await mgr.generate_session("api_key", "api_secret", "request_token") + + +class TestKiteTokenHealth: + def test_dataclass_fields(self): + health = KiteTokenHealth(valid=True, access_token="tok") + assert health.valid + assert health.access_token == "tok" + assert health.expires_at is None + assert health.remaining is None + assert health.warning == "" diff --git a/tests/unit/broker/test_market_data.py b/tests/unit/broker/test_market_data.py new file mode 100644 index 0000000..082304e --- /dev/null +++ b/tests/unit/broker/test_market_data.py @@ -0,0 +1,278 @@ +"""Tests for MarketDataProvider.""" + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from skopaq.broker.market_data import ( + MarketDataProvider, + _to_yfinance_symbol, +) +from skopaq.broker.models import Quote + + +@pytest.fixture +def mock_config(): + config = MagicMock() + config.broker = "indstocks" + config.asset_class = "equity" + config.binance_base_url = "https://api.binance.com" + return config + + +@pytest.fixture +def provider(mock_config): + return MarketDataProvider(mock_config, stale_threshold=5.0) + + +class TestYfinanceSymbolConversion: + def test_plain_indian_equity(self): + assert _to_yfinance_symbol("RELIANCE") == "RELIANCE.NS" + + def test_already_ns(self): + assert _to_yfinance_symbol("RELIANCE.NS") == "RELIANCE.NS" + + def test_already_bo(self): + assert _to_yfinance_symbol("RELIANCE.BO") == "RELIANCE.BO" + + def test_crypto_usdt(self): + assert _to_yfinance_symbol("BTCUSDT") == "BTC-USD" + + def test_crypto_already_usd(self): + assert _to_yfinance_symbol("BTC-USD") == "BTC-USD" + + def test_kite_nse_prefix(self): + assert _to_yfinance_symbol("NSE:RELIANCE") == "RELIANCE.NS" + + def test_kite_bse_prefix(self): + assert _to_yfinance_symbol("BSE:RELIANCE") == "RELIANCE.BO" + + def test_crypto_busd(self): + assert _to_yfinance_symbol("ETHBUSD") == "ETH-USD" + + def test_short_symbol(self): + # "ETH" alone shouldn't trigger crypto detection + assert _to_yfinance_symbol("ETH") == "ETH.NS" + + +class TestMarketDataProviderCache: + def test_put_and_get_fresh(self, provider): + quote = Quote(symbol="RELIANCE", ltp=2500.0) + provider._put_cache("RELIANCE", quote) + cached = provider._get_cached("RELIANCE") + assert cached is not None + assert cached.ltp == 2500.0 + + def test_stale_cache_returns_none(self, provider): + quote = Quote(symbol="RELIANCE", ltp=2500.0) + provider._put_cache("RELIANCE", quote) + # Fake the timestamp to be old + provider._cache["RELIANCE"] = (quote, time.monotonic() - 100) + assert provider._get_cached("RELIANCE") is None + + def test_stale_cache_with_ignore(self, provider): + quote = Quote(symbol="RELIANCE", ltp=2500.0) + provider._put_cache("RELIANCE", quote) + provider._cache["RELIANCE"] = (quote, time.monotonic() - 100) + cached = provider._get_cached("RELIANCE", ignore_staleness=True) + assert cached is not None + assert cached.ltp == 2500.0 + + def test_missing_cache_returns_none(self, provider): + assert provider._get_cached("NONEXISTENT") is None + + def test_clear_cache(self, provider): + provider._put_cache("A", Quote(symbol="A", ltp=100)) + provider._put_cache("B", Quote(symbol="B", ltp=200)) + provider.clear_cache() + assert provider._get_cached("A") is None + assert provider._get_cached("B") is None + + +class TestMarketDataProviderInject: + def test_inject_quote(self, provider): + quote = Quote(symbol="TCS", ltp=3500.0) + provider.inject_quote(quote) + cached = provider._get_cached("TCS") + assert cached is not None + assert cached.ltp == 3500.0 + + +@pytest.mark.asyncio +class TestMarketDataProviderBrokerSource: + async def test_broker_quote_indstocks(self, provider): + mock_client = AsyncMock() + mock_client.get_quote = AsyncMock( + return_value=Quote(symbol="RELIANCE", ltp=2500.0, bid=2499.0, ask=2501.0) + ) + provider.set_broker_client(mock_client) + + with patch("skopaq.broker.scrip_resolver.resolve_scrip_code", new_callable=AsyncMock) as mock_resolve: + mock_resolve.return_value = "NSE_2885" + quote = await provider._fetch_broker_quote("RELIANCE") + + assert quote is not None + assert quote.ltp == 2500.0 + assert quote.bid == 2499.0 + + async def test_broker_quote_kite(self, mock_config): + mock_config.broker = "kite" + p = MarketDataProvider(mock_config) + + mock_client = AsyncMock() + mock_client.get_quote = AsyncMock( + return_value=Quote(symbol="RELIANCE", ltp=2500.0) + ) + p.set_broker_client(mock_client) + + quote = await p._fetch_broker_quote("RELIANCE") + assert quote is not None + assert quote.ltp == 2500.0 + mock_client.get_quote.assert_called_once_with("NSE:RELIANCE", symbol="RELIANCE") + + async def test_no_broker_returns_none(self, provider): + quote = await provider._fetch_broker_quote("RELIANCE") + assert quote is None + + async def test_broker_error_returns_none(self, provider): + mock_client = AsyncMock() + mock_client.get_quote = AsyncMock(side_effect=Exception("Token expired")) + provider.set_broker_client(mock_client) + + with patch("skopaq.broker.scrip_resolver.resolve_scrip_code", new_callable=AsyncMock) as mock_resolve: + mock_resolve.return_value = "NSE_2885" + quote = await provider._fetch_broker_quote("RELIANCE") + + assert quote is None + + +@pytest.mark.asyncio +class TestMarketDataProviderYfinance: + async def test_yfinance_fallback(self, provider): + """Test that yfinance is called when no broker is attached.""" + fake_quote = Quote(symbol="RELIANCE", ltp=2500.0, exchange="NSE") + + with patch.object(provider, "_fetch_yfinance_quote", new_callable=AsyncMock) as mock_yf: + mock_yf.return_value = fake_quote + quote = await provider.get_quote("RELIANCE") + + assert quote.ltp == 2500.0 + mock_yf.assert_called_once() + + async def test_yfinance_sync_import_error(self): + """Test graceful handling when yfinance is not installed.""" + with patch.dict("sys.modules", {"yfinance": None}): + result = MarketDataProvider._yfinance_sync("RELIANCE") + # Should return None, not raise + assert result is None + + +@pytest.mark.asyncio +class TestMarketDataProviderGetQuote: + async def test_returns_cached_if_fresh(self, provider): + provider._put_cache("RELIANCE", Quote(symbol="RELIANCE", ltp=2500.0)) + quote = await provider.get_quote("RELIANCE") + assert quote.ltp == 2500.0 + + async def test_tries_broker_then_yfinance(self, provider): + """When broker fails, falls back to yfinance.""" + # Mock broker to fail + with patch.object( + provider, "_fetch_broker_quote", new_callable=AsyncMock, return_value=None, + ): + with patch.object( + provider, "_fetch_yfinance_quote", new_callable=AsyncMock, + ) as mock_yf: + mock_yf.return_value = Quote(symbol="RELIANCE", ltp=2500.0) + quote = await provider.get_quote("RELIANCE") + + assert quote.ltp == 2500.0 + mock_yf.assert_called_once() + + async def test_returns_stale_on_total_failure(self, provider): + """When all sources fail, returns stale cache.""" + provider._put_cache("RELIANCE", Quote(symbol="RELIANCE", ltp=2500.0)) + # Make it stale + provider._cache["RELIANCE"] = ( + provider._cache["RELIANCE"][0], + time.monotonic() - 100, + ) + + with patch.object( + provider, "_fetch_broker_quote", new_callable=AsyncMock, return_value=None, + ): + with patch.object( + provider, "_fetch_yfinance_quote", new_callable=AsyncMock, return_value=None, + ): + quote = await provider.get_quote("RELIANCE") + + assert quote.ltp == 2500.0 # Stale but better than nothing + + async def test_returns_empty_quote_on_total_miss(self, provider): + """When no cache and all sources fail, returns zero Quote.""" + with patch.object( + provider, "_fetch_broker_quote", new_callable=AsyncMock, return_value=None, + ): + with patch.object( + provider, "_fetch_yfinance_quote", new_callable=AsyncMock, return_value=None, + ): + quote = await provider.get_quote("NONEXISTENT") + + assert quote.symbol == "NONEXISTENT" + assert quote.ltp == 0.0 + + +@pytest.mark.asyncio +class TestMarketDataProviderGetLtp: + async def test_get_ltp(self, provider): + provider._put_cache("RELIANCE", Quote(symbol="RELIANCE", ltp=2500.0)) + ltp = await provider.get_ltp("RELIANCE") + assert ltp == 2500.0 + + async def test_get_ltp_zero_on_miss(self, provider): + with patch.object( + provider, "_fetch_broker_quote", new_callable=AsyncMock, return_value=None, + ): + with patch.object( + provider, "_fetch_yfinance_quote", new_callable=AsyncMock, return_value=None, + ): + ltp = await provider.get_ltp("NONEXISTENT") + assert ltp == 0.0 + + +@pytest.mark.asyncio +class TestMarketDataProviderGetQuotes: + async def test_get_quotes_concurrent(self, provider): + provider._put_cache("A", Quote(symbol="A", ltp=100)) + provider._put_cache("B", Quote(symbol="B", ltp=200)) + quotes = await provider.get_quotes(["A", "B"]) + assert len(quotes) == 2 + assert quotes[0].ltp == 100 + assert quotes[1].ltp == 200 + + +@pytest.mark.asyncio +class TestMarketDataProviderCrypto: + async def test_binance_fallback_for_crypto(self): + config = MagicMock() + config.broker = "indstocks" + config.asset_class = "crypto" + config.binance_base_url = "https://api.binance.com" + provider = MarketDataProvider(config) + + with patch.object( + provider, "_fetch_broker_quote", new_callable=AsyncMock, return_value=None, + ): + with patch.object( + provider, "_fetch_yfinance_quote", new_callable=AsyncMock, return_value=None, + ): + with patch.object( + provider, "_fetch_binance_quote", new_callable=AsyncMock, + return_value=Quote(symbol="BTCUSDT", ltp=65000.0), + ) as mock_binance: + quote = await provider.get_quote("BTCUSDT") + + assert quote.ltp == 65000.0 + mock_binance.assert_called_once() diff --git a/tests/unit/broker/test_paper_engine_async.py b/tests/unit/broker/test_paper_engine_async.py new file mode 100644 index 0000000..805ac7b --- /dev/null +++ b/tests/unit/broker/test_paper_engine_async.py @@ -0,0 +1,240 @@ +"""Tests for PaperEngine async methods (MarketDataProvider integration). + +Covers: execute_order_async, refresh_quote, get_positions_live, set_market_data. +These methods enable live-data paper trading with auto-refreshed quotes. +""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from skopaq.broker.models import ( + Exchange, + OrderRequest, + OrderType, + Product, + Quote, + Side, +) +from skopaq.broker.paper_engine import PaperEngine + + +def _market_buy(symbol="RELIANCE", qty=10): + return OrderRequest( + symbol=symbol, exchange=Exchange.NSE, side=Side.BUY, + quantity=qty, order_type=OrderType.MARKET, product=Product.CNC, + ) + + +def _quote(symbol="RELIANCE", ltp=2500.0): + return Quote(symbol=symbol, exchange="NSE", ltp=ltp, bid=ltp - 1, ask=ltp + 1) + + +def _mock_provider(**overrides): + """Create a mock MarketDataProvider with correct sync/async signatures.""" + provider = AsyncMock() + # inject_quote is synchronous — override to avoid coroutine warnings + provider.inject_quote = MagicMock() + for k, v in overrides.items(): + setattr(provider, k, v) + return provider + + +# ── set_market_data ────────────────────────────────────────────────────────── + + +class TestSetMarketData: + def test_attaches_provider(self): + engine = PaperEngine() + provider = MagicMock() + engine.set_market_data(provider) + assert engine._market_data is provider + + def test_constructor_accepts_market_data(self): + provider = MagicMock() + engine = PaperEngine(market_data=provider) + assert engine._market_data is provider + + def test_default_is_none(self): + engine = PaperEngine() + assert engine._market_data is None + + +# ── update_quote with provider ─────────────────────────────────────────────── + + +class TestUpdateQuoteWithProvider: + def test_update_quote_pushes_to_provider_cache(self): + provider = _mock_provider() + engine = PaperEngine(market_data=provider) + quote = _quote("RELIANCE", 2500) + engine.update_quote(quote) + + provider.inject_quote.assert_called_once_with(quote) + assert engine.get_quote("RELIANCE") is quote + + def test_update_quote_without_provider_still_works(self): + engine = PaperEngine() + quote = _quote("RELIANCE", 2500) + engine.update_quote(quote) + assert engine.get_quote("RELIANCE") is quote + + +# ── refresh_quote ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestRefreshQuote: + async def test_fetches_from_provider(self): + provider = _mock_provider() + fresh = _quote("RELIANCE", 2550) + provider.get_quote = AsyncMock(return_value=fresh) + + engine = PaperEngine(market_data=provider) + result = await engine.refresh_quote("RELIANCE") + + provider.get_quote.assert_called_once_with("RELIANCE") + assert result.ltp == 2550 + assert engine.get_quote("RELIANCE").ltp == 2550 + + async def test_falls_back_to_cache_without_provider(self): + engine = PaperEngine() + engine.update_quote(_quote("RELIANCE", 2500)) + result = await engine.refresh_quote("RELIANCE") + assert result.ltp == 2500 + + async def test_returns_none_when_no_cache_no_provider(self): + engine = PaperEngine() + result = await engine.refresh_quote("NONEXISTENT") + assert result is None + + async def test_ignores_zero_ltp_from_provider(self): + provider = _mock_provider() + provider.get_quote = AsyncMock(return_value=_quote("RELIANCE", 0.0)) + + engine = PaperEngine(market_data=provider) + engine.update_quote(_quote("RELIANCE", 2500)) + result = await engine.refresh_quote("RELIANCE") + + # Should keep old cached value since provider returned 0 + assert result.ltp == 2500 + + +# ── execute_order_async ────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestExecuteOrderAsync: + async def test_refreshes_quote_before_fill(self): + """execute_order_async should fetch fresh quote then fill.""" + provider = _mock_provider() + fresh = _quote("RELIANCE", 2550) + provider.get_quote = AsyncMock(return_value=fresh) + + engine = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + market_data=provider, + ) + # Inject stale quote + engine.update_quote(_quote("RELIANCE", 2500)) + + order = _market_buy("RELIANCE", qty=10) + result = await engine.execute_order_async(order) + + assert result.success + # Fill should be at the REFRESHED ask price (2551), not stale (2501) + assert result.fill_price == 2551.0 + provider.get_quote.assert_called_with("RELIANCE") + + async def test_works_without_provider(self): + """Without provider, behaves like sync execute_order.""" + engine = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + ) + engine.update_quote(_quote("RELIANCE", 2500)) + + order = _market_buy("RELIANCE", qty=10) + result = await engine.execute_order_async(order) + + assert result.success + assert result.fill_price == 2501.0 # ask from stale quote + + async def test_provider_failure_uses_cached(self): + """If provider returns 0 LTP, fall back to cached quote.""" + provider = _mock_provider() + provider.get_quote = AsyncMock(return_value=_quote("RELIANCE", 0.0)) + + engine = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + market_data=provider, + ) + engine.update_quote(_quote("RELIANCE", 2500)) + + order = _market_buy("RELIANCE", qty=10) + result = await engine.execute_order_async(order) + + assert result.success + assert result.fill_price == 2501.0 # Used cached quote + + async def test_no_quote_anywhere_rejects(self): + """No cached quote and provider returns 0 → rejected.""" + provider = _mock_provider() + provider.get_quote = AsyncMock(return_value=_quote("UNKNOWN", 0.0)) + + engine = PaperEngine( + initial_capital=1_000_000, market_data=provider, + ) + order = _market_buy("UNKNOWN", qty=10) + result = await engine.execute_order_async(order) + + assert not result.success + assert "No quote" in result.rejection_reason + + +# ── get_positions_live ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestGetPositionsLive: + async def test_refreshes_quotes_for_open_positions(self): + provider = _mock_provider() + provider.get_quotes = AsyncMock(return_value=[ + _quote("RELIANCE", 2600), + ]) + + engine = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + market_data=provider, + ) + engine.update_quote(_quote("RELIANCE", 2500)) + engine.execute_order(_market_buy("RELIANCE", qty=10)) + + positions = await engine.get_positions_live() + + provider.get_quotes.assert_called_once_with(["RELIANCE"]) + assert len(positions) == 1 + assert positions[0].last_price == 2600.0 + # P&L should reflect refreshed price: (2600 - 2501) * 10 = 990 + assert positions[0].pnl == pytest.approx(990.0, abs=1) + + async def test_no_positions_no_fetch(self): + provider = _mock_provider() + provider.get_quotes = AsyncMock() + + engine = PaperEngine(market_data=provider) + positions = await engine.get_positions_live() + + assert positions == [] + provider.get_quotes.assert_not_called() + + async def test_without_provider_returns_cached(self): + engine = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + ) + engine.update_quote(_quote("RELIANCE", 2500)) + engine.execute_order(_market_buy("RELIANCE", qty=10)) + + positions = await engine.get_positions_live() + assert len(positions) == 1 + assert positions[0].last_price == 2500.0 diff --git a/tests/unit/broker/test_upstox_client.py b/tests/unit/broker/test_upstox_client.py new file mode 100644 index 0000000..9bc8661 --- /dev/null +++ b/tests/unit/broker/test_upstox_client.py @@ -0,0 +1,185 @@ +"""Tests for Upstox API v2 client.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from skopaq.broker.upstox_client import UpstoxClient, UpstoxError, _EXCHANGE_SEGMENT_MAP + + +@pytest.fixture +def client(): + return UpstoxClient(access_token="test_access_token") + + +class TestUpstoxInit: + def test_creates_with_token(self, client): + assert client._access_token == "test_access_token" + assert client._client is None + + def test_headers(self, client): + headers = client._headers() + assert headers["Authorization"] == "Bearer test_access_token" + + +class TestInstrumentKey: + def test_nse_equity(self): + assert UpstoxClient._build_instrument_key("RELIANCE", "NSE") == "NSE_EQ|RELIANCE" + + def test_bse_equity(self): + assert UpstoxClient._build_instrument_key("TCS", "BSE") == "BSE_EQ|TCS" + + def test_nfo(self): + assert UpstoxClient._build_instrument_key("NIFTY", "NFO") == "NSE_FO|NIFTY" + + def test_default_nse(self): + assert UpstoxClient._build_instrument_key("INFY") == "NSE_EQ|INFY" + + def test_uppercase(self): + assert UpstoxClient._build_instrument_key("reliance") == "NSE_EQ|RELIANCE" + + +class TestLoginUrl: + def test_format(self): + url = UpstoxClient.get_login_url("my_key", "http://localhost/callback") + assert "client_id=my_key" in url + assert "redirect_uri=http://localhost/callback" in url + assert "response_type=code" in url + + +@pytest.mark.asyncio +class TestUpstoxContextManager: + async def test_aenter_creates_client(self, client): + async with client as c: + assert c._client is not None + assert client._client is None + + +@pytest.mark.asyncio +class TestUpstoxQuote: + async def test_get_quote(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "NSE_EQ:RELIANCE": { + "last_price": 2500.0, + "ohlc": { + "open": 2490.0, + "high": 2510.0, + "low": 2480.0, + "close": 2470.0, + }, + "volume": 500000, + "depth": { + "buy": [{"price": 2499.0, "quantity": 100}], + "sell": [{"price": 2501.0, "quantity": 50}], + }, + } + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + quote = await client.get_quote("RELIANCE") + + assert quote.symbol == "RELIANCE" + assert quote.ltp == 2500.0 + assert quote.open == 2490.0 + assert quote.high == 2510.0 + assert quote.low == 2480.0 + assert quote.close == 2470.0 + assert quote.volume == 500000 + assert quote.bid == 2499.0 + assert quote.ask == 2501.0 + assert quote.change == 30.0 + assert quote.change_pct == pytest.approx(1.21, abs=0.01) + + async def test_get_ltp(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "NSE_EQ:RELIANCE": {"last_price": 2500.0} + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + ltp = await client.get_ltp("RELIANCE") + assert ltp == 2500.0 + + async def test_empty_data_returns_zero(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "success", "data": {}} + client._client.request = AsyncMock(return_value=mock_resp) + + ltp = await client.get_ltp("NONEXISTENT") + assert ltp == 0.0 + + +@pytest.mark.asyncio +class TestUpstoxHistorical: + async def test_get_historical(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "success", + "data": { + "candles": [ + ["2024-01-15T00:00:00+05:30", 2490, 2510, 2480, 2500, 500000, 0], + ["2024-01-16T00:00:00+05:30", 2500, 2520, 2490, 2510, 600000, 0], + ] + }, + } + client._client.request = AsyncMock(return_value=mock_resp) + + candles = await client.get_historical("RELIANCE") + assert len(candles) == 2 + assert candles[0].open == 2490 + assert candles[0].close == 2500 + assert candles[0].volume == 500000 + + +@pytest.mark.asyncio +class TestUpstoxErrors: + async def test_api_error_response(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "status": "error", + "errors": [{"message": "Invalid token", "errorCode": "UDAPI100050"}], + } + client._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(UpstoxError, match="Invalid token"): + await client._request("GET", "/test") + + async def test_http_error(self, client): + async with client: + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.text = "Unauthorized" + client._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(UpstoxError, match="API error 401"): + await client._request("GET", "/test") + + async def test_not_initialized(self): + client = UpstoxClient(access_token="test") + with pytest.raises(UpstoxError, match="not initialised"): + await client._request("GET", "/test") + + +class TestExchangeSegmentMap: + def test_all_segments_defined(self): + assert "NSE" in _EXCHANGE_SEGMENT_MAP + assert "BSE" in _EXCHANGE_SEGMENT_MAP + assert "NFO" in _EXCHANGE_SEGMENT_MAP + assert "MCX" in _EXCHANGE_SEGMENT_MAP diff --git a/tests/unit/execution/test_daemon.py b/tests/unit/execution/test_daemon.py index fe5c642..35a923e 100644 --- a/tests/unit/execution/test_daemon.py +++ b/tests/unit/execution/test_daemon.py @@ -35,6 +35,7 @@ def config(): """Minimal config mock for daemon tests.""" cfg = MagicMock() cfg.trading_mode = "paper" + cfg.broker = "indstocks" cfg.daemon_max_trades_per_session = 3 cfg.daemon_max_candidates_to_analyze = 5 cfg.daemon_pre_open_minutes = 5 @@ -243,11 +244,13 @@ async def test_analyze_respects_max_trades(daemon): mock_result.execution = MagicMock(success=True, fill_price=100.0) daemon._graph.analyze_and_execute = AsyncMock(return_value=mock_result) + daemon._market_data = AsyncMock() + daemon._market_data.get_quote = AsyncMock() + report = DaemonSessionReport() with patch("skopaq.cli.main._compute_risk_scales", return_value=(1.0, 1.0)): - with patch("skopaq.cli.main._inject_paper_quote", new_callable=AsyncMock): - buys = await daemon._phase_analyze_and_trade(candidates, report) + buys = await daemon._phase_analyze_and_trade(candidates, report) assert report.trades_opened == 1 # Only 1 trade, despite 2 candidates assert len(buys) == 1 @@ -287,12 +290,14 @@ async def test_analyze_skips_hold_signals(daemon): mock_result.execution = None daemon._graph.analyze_and_execute = AsyncMock(return_value=mock_result) + daemon._market_data = AsyncMock() + daemon._market_data.get_quote = AsyncMock() + candidates = [MagicMock(symbol="AAA", urgency="high")] report = DaemonSessionReport() with patch("skopaq.cli.main._compute_risk_scales", return_value=(1.0, 1.0)): - with patch("skopaq.cli.main._inject_paper_quote", new_callable=AsyncMock): - buys = await daemon._phase_analyze_and_trade(candidates, report) + buys = await daemon._phase_analyze_and_trade(candidates, report) assert report.holds == 1 assert report.trades_opened == 0 @@ -322,11 +327,13 @@ async def test_analyze_handles_errors_gracefully(daemon): MagicMock(symbol="FAIL", urgency="high"), MagicMock(symbol="OK", urgency="normal"), ] + daemon._market_data = AsyncMock() + daemon._market_data.get_quote = AsyncMock() + report = DaemonSessionReport() with patch("skopaq.cli.main._compute_risk_scales", return_value=(1.0, 1.0)): - with patch("skopaq.cli.main._inject_paper_quote", new_callable=AsyncMock): - buys = await daemon._phase_analyze_and_trade(candidates, report) + buys = await daemon._phase_analyze_and_trade(candidates, report) assert report.candidates_analyzed == 2 assert report.trades_opened == 1 # Second candidate succeeded @@ -341,6 +348,7 @@ async def test_monitor_receives_positions(daemon): """Monitor phase creates a PositionMonitor and runs it.""" daemon._executor = MagicMock() daemon._client = MagicMock() + daemon._market_data = MagicMock() daemon._router = MagicMock() daemon._llm_map = {"sell_analyst": MagicMock()} diff --git a/tests/unit/execution/test_executor.py b/tests/unit/execution/test_executor.py new file mode 100644 index 0000000..52219cd --- /dev/null +++ b/tests/unit/execution/test_executor.py @@ -0,0 +1,346 @@ +"""Tests for Executor — signal-to-order translation and safety pipeline. + +Covers: _build_order, _cap_quantity, execute_signal pipeline. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from skopaq.broker.models import ( + ExecutionResult, + Exchange, + Funds, + OrderRequest, + OrderResponse, + OrderType, + Position, + Product, + Side, + TradingSignal, +) +from skopaq.broker.paper_engine import PaperEngine +from skopaq.execution.executor import Executor +from skopaq.execution.order_router import OrderRouter +from skopaq.execution.safety_checker import SafetyChecker + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _make_executor( + mode="paper", rules=None, sizer=None, product="CNC", +) -> tuple[Executor, PaperEngine, OrderRouter]: + """Build a minimal Executor stack for testing.""" + from skopaq.constants import PAPER_SAFETY_RULES + + config = MagicMock() + config.trading_mode = mode + config.broker = "indstocks" + config.max_sector_concentration_pct = 0.40 + + paper = PaperEngine(initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0) + router = OrderRouter(config, paper) + safety = SafetyChecker( + rules=rules or PAPER_SAFETY_RULES, + max_sector_concentration_pct=0.40, + ) + executor = Executor(router, safety, position_sizer=sizer, product=product) + return executor, paper, router + + +def _buy_signal(symbol="RELIANCE", price=2500.0, qty=10, confidence=70): + return TradingSignal( + symbol=symbol, + action="BUY", + confidence=confidence, + entry_price=price, + stop_loss=price * 0.96, + quantity=Decimal(qty), + ) + + +def _sell_signal(symbol="RELIANCE", price=2500.0, qty=10, confidence=70): + return TradingSignal( + symbol=symbol, + action="SELL", + confidence=confidence, + entry_price=price, + quantity=Decimal(qty), + ) + + +def _hold_signal(symbol="RELIANCE"): + return TradingSignal( + symbol=symbol, + action="HOLD", + confidence=50, + ) + + +# ── _build_order tests ────────────────────────────────────────────────────── + + +class TestBuildOrder: + def test_buy_signal_creates_buy_order(self): + executor, _, _ = _make_executor() + signal = _buy_signal() + order = executor._build_order(signal) + + assert order is not None + assert order.side == Side.BUY + assert order.symbol == "RELIANCE" + assert order.quantity == Decimal("10") + assert order.order_type == OrderType.LIMIT + assert order.price == 2500.0 + assert order.product == Product.CNC + assert "skopaq-70" in order.tag + + def test_sell_signal_creates_sell_order(self): + executor, _, _ = _make_executor() + signal = _sell_signal() + order = executor._build_order(signal) + + assert order is not None + assert order.side == Side.SELL + assert order.quantity == Decimal("10") + + def test_hold_signal_returns_none(self): + executor, _, _ = _make_executor() + signal = _hold_signal() + order = executor._build_order(signal) + assert order is None + + def test_no_entry_price_creates_market_order(self): + executor, _, _ = _make_executor() + signal = TradingSignal( + symbol="RELIANCE", action="BUY", confidence=70, + entry_price=None, quantity=Decimal(5), + ) + order = executor._build_order(signal) + + assert order is not None + assert order.order_type == OrderType.MARKET + assert order.price is None + + def test_buy_with_stop_loss_sets_trigger_price(self): + executor, _, _ = _make_executor() + signal = _buy_signal(price=2500, qty=10) + signal.stop_loss = 2400.0 + order = executor._build_order(signal) + + assert order.trigger_price == 2400.0 + + def test_sell_does_not_set_trigger_price(self): + executor, _, _ = _make_executor() + signal = _sell_signal() + signal.stop_loss = 2400.0 + order = executor._build_order(signal) + + assert order.trigger_price is None + + def test_default_quantity_is_1(self): + executor, _, _ = _make_executor() + signal = TradingSignal( + symbol="RELIANCE", action="BUY", confidence=70, + entry_price=2500.0, quantity=None, + ) + order = executor._build_order(signal) + assert order.quantity == 1 + + def test_exchange_from_signal(self): + executor, _, _ = _make_executor() + signal = TradingSignal( + symbol="RELIANCE", action="BUY", confidence=70, + exchange=Exchange.BSE, entry_price=2500.0, quantity=Decimal(5), + ) + order = executor._build_order(signal) + assert order.exchange == Exchange.BSE + + +# ── _cap_quantity tests ────────────────────────────────────────────────────── + + +class TestCapQuantity: + def test_respects_max_lots(self): + executor, _, _ = _make_executor() + # PAPER_SAFETY_RULES.max_lots_per_position = 5 + capped = executor._cap_quantity(raw_qty=100, price=100.0, equity=1_000_000) + assert capped <= 5 + + def test_respects_max_position_pct(self): + executor, _, _ = _make_executor() + # max_position_pct=0.15 → 150K max on 1M equity → at 2500/share = 60 max + capped = executor._cap_quantity(raw_qty=100, price=2500.0, equity=1_000_000) + assert capped <= 60 + + def test_respects_max_order_value(self): + executor, _, _ = _make_executor() + # PAPER_SAFETY_RULES.max_order_value_inr = 500_000 + # At 100K/share → max 5 shares + capped = executor._cap_quantity(raw_qty=100, price=100_000.0, equity=10_000_000) + assert capped <= 5 + + def test_minimum_is_1(self): + executor, _, _ = _make_executor() + capped = executor._cap_quantity(raw_qty=0, price=2500.0, equity=1_000_000) + assert capped == 1 + + def test_zero_price_returns_raw_capped(self): + executor, _, _ = _make_executor() + capped = executor._cap_quantity(raw_qty=3, price=0.0, equity=1_000_000) + assert capped == 3 # Only max_lots cap applies + + +# ── execute_signal pipeline tests ──────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestExecuteSignal: + async def test_buy_fills_in_paper_mode(self): + executor, paper, _ = _make_executor() + from skopaq.broker.models import Quote + paper.update_quote(Quote( + symbol="RELIANCE", ltp=2500, bid=2499, ask=2501, + )) + + signal = _buy_signal("RELIANCE", price=2500, qty=5) + result = await executor.execute_signal(signal) + + assert result.success + assert result.mode == "paper" + + async def test_hold_signal_rejected(self): + executor, _, _ = _make_executor() + signal = _hold_signal() + result = await executor.execute_signal(signal) + + assert not result.success + assert "Cannot build order" in result.rejection_reason + + async def test_sell_records_pnl(self): + executor, paper, _ = _make_executor() + from skopaq.broker.models import Quote + paper.update_quote(Quote( + symbol="RELIANCE", ltp=2600, bid=2599, ask=2601, + )) + + # First buy + buy_signal = _buy_signal("RELIANCE", price=2600, qty=5) + await executor.execute_signal(buy_signal) + + # Then sell at higher price + paper.update_quote(Quote( + symbol="RELIANCE", ltp=2700, bid=2699, ask=2701, + )) + sell_signal = _sell_signal("RELIANCE", price=2700, qty=5) + result = await executor.execute_signal(sell_signal) + + assert result.success + + async def test_safety_rejection(self): + """Order exceeding safety limits should be rejected.""" + from skopaq.constants import PAPER_SAFETY_RULES + + executor, paper, _ = _make_executor() + from skopaq.broker.models import Quote + paper.update_quote(Quote( + symbol="RELIANCE", ltp=2500, bid=2499, ask=2501, + )) + + # Try to buy way more than max_order_value allows + signal = TradingSignal( + symbol="RELIANCE", action="BUY", confidence=70, + entry_price=2500.0, + stop_loss=2400.0, + quantity=Decimal(10000), # 10000 * 2500 = 25M >> 500K limit + ) + result = await executor.execute_signal(signal) + + assert not result.success + assert not result.safety_passed + + async def test_entry_price_resolved_from_yfinance(self): + """When entry_price is missing, executor tries yfinance.""" + executor, paper, _ = _make_executor() + from skopaq.broker.models import Quote + paper.update_quote(Quote( + symbol="RELIANCE", ltp=2500, bid=2499, ask=2501, + )) + + signal = TradingSignal( + symbol="RELIANCE", action="BUY", confidence=70, + entry_price=None, # Missing + stop_loss=2400.0, + quantity=Decimal(5), + ) + + with patch.object( + Executor, "_fetch_current_price", return_value=2500.0, + ): + result = await executor.execute_signal(signal) + + assert signal.entry_price == 2500.0 # Should have been resolved + + +# ── Intraday (MIS) product tests ───────────────────────────────────────── + + +class TestIntradayProduct: + def test_intraday_executor_uses_mis(self): + """Executor with product=MIS should build intraday orders.""" + executor, _, _ = _make_executor(product="MIS") + signal = _buy_signal() + order = executor._build_order(signal) + # MIS maps to Product.INTRADAY (value "INTRADAY") + assert order.product.value == "INTRADAY" + + def test_delivery_executor_uses_cnc(self): + """Executor with default product=CNC should build CNC orders.""" + executor, _, _ = _make_executor(product="CNC") + signal = _buy_signal() + order = executor._build_order(signal) + assert order.product == Product.CNC + + def test_nrml_product(self): + """Executor with product=NRML for F&O.""" + executor, _, _ = _make_executor(product="NRML") + signal = _buy_signal() + order = executor._build_order(signal) + # NRML maps to Product.MARGIN (value "MARGIN") + assert order.product.value == "MARGIN" + + def test_product_persists_across_orders(self): + """Product should be the same for all orders from the same executor.""" + executor, _, _ = _make_executor(product="MIS") + buy_order = executor._build_order(_buy_signal()) + sell_order = executor._build_order(_sell_signal()) + assert buy_order.product.value == "INTRADAY" + assert sell_order.product.value == "INTRADAY" + + +class TestIntradaySafetyRules: + def test_intraday_rules_require_market_hours(self): + from skopaq.constants import INTRADAY_SAFETY_RULES + assert INTRADAY_SAFETY_RULES.market_hours_only is True + + def test_intraday_rules_require_stop_loss(self): + from skopaq.constants import INTRADAY_SAFETY_RULES + assert INTRADAY_SAFETY_RULES.require_stop_loss is True + + def test_intraday_rules_tighter_min_stop(self): + from skopaq.constants import INTRADAY_SAFETY_RULES, SAFETY_RULES + assert INTRADAY_SAFETY_RULES.min_stop_loss_pct < SAFETY_RULES.min_stop_loss_pct + + def test_intraday_paper_relaxes_timing(self): + from skopaq.constants import INTRADAY_PAPER_SAFETY_RULES + assert INTRADAY_PAPER_SAFETY_RULES.market_hours_only is False + assert INTRADAY_PAPER_SAFETY_RULES.require_stop_loss is False + + def test_intraday_tighter_daily_loss(self): + from skopaq.constants import INTRADAY_SAFETY_RULES, SAFETY_RULES + assert INTRADAY_SAFETY_RULES.max_daily_loss_pct < SAFETY_RULES.max_daily_loss_pct diff --git a/tests/unit/execution/test_order_router.py b/tests/unit/execution/test_order_router.py index 8c5d564..f32a81e 100644 --- a/tests/unit/execution/test_order_router.py +++ b/tests/unit/execution/test_order_router.py @@ -21,6 +21,7 @@ def _make_config(mode: str = "paper") -> MagicMock: cfg = MagicMock() cfg.trading_mode = mode + cfg.broker = "indstocks" cfg.initial_paper_capital = 1_000_000.0 return cfg @@ -96,7 +97,7 @@ async def test_live_resolves_security_id(self): order = _buy_order(security_id="") # Empty — needs resolution with patch( - "skopaq.execution.order_router.resolve_security_id", + "skopaq.broker.scrip_resolver.resolve_security_id", new_callable=AsyncMock, return_value="10604", ) as mock_resolve: @@ -122,7 +123,7 @@ async def test_live_skips_resolve_if_security_id_set(self): order = _buy_order(security_id="10604") with patch( - "skopaq.execution.order_router.resolve_security_id", + "skopaq.broker.scrip_resolver.resolve_security_id", new_callable=AsyncMock, ) as mock_resolve: result = await router.execute(order, _signal()) diff --git a/tests/unit/execution/test_order_router_dispatch.py b/tests/unit/execution/test_order_router_dispatch.py new file mode 100644 index 0000000..a36545d --- /dev/null +++ b/tests/unit/execution/test_order_router_dispatch.py @@ -0,0 +1,221 @@ +"""Tests for OrderRouter dispatch logic — async paper path and broker selection. + +Covers: +- Async paper execution with MarketDataProvider +- Sync paper fallback without provider +- Live dispatch to Kite or INDstocks +- get_orders unified interface +- CLI _create_live_client factory +""" + +from __future__ import annotations + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from skopaq.broker.models import ( + Exchange, + ExecutionResult, + Funds, + OrderRequest, + OrderResponse, + OrderType, + Position, + Product, + Quote, + Side, + TradingSignal, +) +from skopaq.broker.paper_engine import PaperEngine +from skopaq.execution.order_router import OrderRouter + + +def _config(mode="paper", broker="indstocks"): + cfg = MagicMock() + cfg.trading_mode = mode + cfg.broker = broker + return cfg + + +def _buy_order(symbol="RELIANCE"): + return OrderRequest( + symbol=symbol, side=Side.BUY, quantity=Decimal("10"), + price=2500.0, order_type=OrderType.LIMIT, product=Product.CNC, + ) + + +def _quote(symbol="RELIANCE", ltp=2500.0): + return Quote(symbol=symbol, ltp=ltp, bid=ltp - 1, ask=ltp + 1) + + +# ── Async paper dispatch ──────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestAsyncPaperDispatch: + async def test_with_market_data_calls_async(self): + """When paper engine has MarketDataProvider, uses execute_order_async.""" + provider = AsyncMock() + provider.inject_quote = MagicMock() # sync method + provider.get_quote = AsyncMock(return_value=_quote("RELIANCE", 2500)) + + paper = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + market_data=provider, + ) + paper.update_quote(_quote("RELIANCE", 2500)) + + router = OrderRouter(_config("paper"), paper) + result = await router.execute(_buy_order()) + + assert result.success + assert result.mode == "paper" + # Verify provider was called for fresh quote + provider.get_quote.assert_called_with("RELIANCE") + + async def test_without_market_data_calls_sync(self): + """Without MarketDataProvider, uses synchronous execute_order.""" + paper = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + ) + paper.update_quote(_quote("RELIANCE", 2500)) + + router = OrderRouter(_config("paper"), paper) + result = await router.execute(_buy_order()) + + assert result.success + assert result.mode == "paper" + + +# ── Live dispatch ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestLiveDispatch: + async def test_live_kite_no_security_id_resolution(self): + """Kite broker should NOT resolve security_id (only INDstocks does).""" + config = _config("live", "kite") + paper = PaperEngine() + + live = AsyncMock() + live.place_order = AsyncMock(return_value=OrderResponse( + order_id="K123", status="PENDING", + )) + + router = OrderRouter(config, paper, live_client=live) + order = _buy_order() + result = await router.execute(order) + + assert result.success + assert result.mode == "live" + live.place_order.assert_called_once_with(order) + # security_id should NOT have been resolved for Kite + assert order.security_id == "" + + async def test_live_indstocks_resolves_security_id(self): + """INDstocks broker should resolve security_id before placing.""" + from unittest.mock import patch + + config = _config("live", "indstocks") + paper = PaperEngine() + + live = AsyncMock() + live.place_order = AsyncMock(return_value=OrderResponse( + order_id="I456", status="PENDING", + )) + + router = OrderRouter(config, paper, live_client=live) + order = _buy_order() + assert order.security_id == "" + + with patch( + "skopaq.broker.scrip_resolver.resolve_security_id", + new_callable=AsyncMock, return_value="2885", + ): + result = await router.execute(order) + + assert result.success + assert order.security_id == "2885" + + +# ── get_orders ─────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +class TestGetOrders: + async def test_paper_mode_returns_paper_orders(self): + paper = PaperEngine( + initial_capital=1_000_000, slippage_pct=0.0, brokerage=0.0, + ) + paper.update_quote(_quote("RELIANCE", 2500)) + paper.execute_order(_buy_order()) + + router = OrderRouter(_config("paper"), paper) + orders = await router.get_orders() + assert len(orders) == 1 + assert orders[0].order_id.startswith("PAPER-") + + async def test_live_mode_returns_broker_orders(self): + live = AsyncMock() + live.get_orders = AsyncMock(return_value=[ + OrderResponse(order_id="ORD1", status="COMPLETE"), + OrderResponse(order_id="ORD2", status="PENDING"), + ]) + + router = OrderRouter( + _config("live"), PaperEngine(), live_client=live, + ) + orders = await router.get_orders() + assert len(orders) == 2 + live.get_orders.assert_called_once() + + +# ── Broker property ────────────────────────────────────────────────────────── + + +class TestBrokerProperty: + def test_broker_property_returns_config_value(self): + router = OrderRouter(_config("paper", "kite"), PaperEngine()) + assert router.broker == "kite" + + def test_broker_property_default(self): + router = OrderRouter(_config("paper", "indstocks"), PaperEngine()) + assert router.broker == "indstocks" + + +# ── CLI _create_live_client factory ────────────────────────────────────────── + + +try: + import typer # noqa: F401 + _HAS_TYPER = True +except ImportError: + _HAS_TYPER = False + + +@pytest.mark.skipif(not _HAS_TYPER, reason="typer not installed") +class TestCreateLiveClient: + def test_creates_kite_client(self): + from skopaq.cli.main import _create_live_client + + config = MagicMock() + config.broker = "kite" + config.kite_api_key = MagicMock() + config.kite_api_key.get_secret_value.return_value = "test_api_key" + + from skopaq.broker.kite_client import KiteConnectClient + client = _create_live_client(config) + assert isinstance(client, KiteConnectClient) + + def test_creates_indstocks_client(self): + from skopaq.cli.main import _create_live_client + + config = MagicMock() + config.broker = "indstocks" + config.indstocks_base_url = "https://api.indstocks.com" + + from skopaq.broker.client import INDstocksClient + client = _create_live_client(config) + assert isinstance(client, INDstocksClient) diff --git a/tests/unit/execution/test_position_monitor.py b/tests/unit/execution/test_position_monitor.py index 7c7c14b..290f67d 100644 --- a/tests/unit/execution/test_position_monitor.py +++ b/tests/unit/execution/test_position_monitor.py @@ -42,15 +42,20 @@ def config(): cfg.trading_mode = "paper" cfg.initial_paper_capital = 100_000 cfg.max_sector_concentration_pct = 0.40 + cfg.daemon_min_profit_threshold_pct = 0.5 + cfg.daemon_min_profit_threshold_inr = 150.0 return cfg @pytest.fixture -def mock_client(): - """Mock INDstocksClient with LTP support.""" - client = AsyncMock() - client.get_ltp = AsyncMock(return_value=100.0) - return client +def mock_market_data(): + """Mock MarketDataProvider for LTP/quote fetching.""" + md = AsyncMock() + md.get_ltp = AsyncMock(return_value=100.0) + md.get_quote = AsyncMock(return_value=Quote( + symbol="TEST", ltp=100.0, bid=99.9, ask=100.1, + )) + return md @pytest.fixture @@ -89,13 +94,13 @@ def mock_llm(): def _make_monitor( - executor, client, router, config, + executor, market_data, router, config, llm=None, stop_event=None, ai_enabled=True, ) -> PositionMonitor: """Helper to build a PositionMonitor with mocks.""" return PositionMonitor( executor=executor, - client=client, + market_data=market_data, router=router, config=config, llm=llm, @@ -116,8 +121,7 @@ def test_hard_stop_triggers_sell(self, config): MagicMock(), MagicMock(), MagicMock(), config, ai_enabled=False, ) pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) # LTP at 95 = 5% below entry > 4% threshold reason = mon._check_safety(pos, ltp=95.0) @@ -130,12 +134,9 @@ def test_no_stop_within_range(self, config): MagicMock(), MagicMock(), MagicMock(), config, ai_enabled=False, ) pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) # LTP at 98 = 2% below entry < 4% threshold - reason = mon._check_safety(pos, ltp=98.0) - # Should only be None if not EOD time # Patch time to be during market hours (not EOD) with patch( "skopaq.execution.position_monitor.datetime" @@ -153,8 +154,7 @@ def test_trailing_stop_ratchets_up(self, config): MagicMock(), MagicMock(), MagicMock(), config, ai_enabled=False, ) pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, high_water_mark=110.0, # Price went up to 110 ) # LTP at 107.5 — within 2% of HWM (trail = 110 * 0.98 = 107.8) @@ -175,8 +175,7 @@ def test_trailing_stop_does_not_ratchet_down(self, config): MagicMock(), MagicMock(), MagicMock(), config, ai_enabled=False, ) pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, high_water_mark=110.0, ) # LTP at 109 — above trail (110 * 0.98 = 107.8) @@ -195,8 +194,7 @@ def test_eod_exit(self, config): MagicMock(), MagicMock(), MagicMock(), config, ai_enabled=False, ) pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) # Mock time to 15:25 IST (past 15:20 threshold) with patch( @@ -232,8 +230,7 @@ async def test_ai_sell_triggers_execution(self, config, mock_llm): return_value=sell_decision, ): pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) decision = await mon._check_ai(pos, ltp=105.0, pnl_pct=5.0) assert decision is not None @@ -256,8 +253,7 @@ async def test_ai_hold_keeps_position(self, config, mock_llm): return_value=hold_decision, ): pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) decision = await mon._check_ai(pos, ltp=102.0, pnl_pct=2.0) assert decision is not None @@ -271,8 +267,7 @@ async def test_ai_failure_defaults_to_hold(self, config): llm=None, ai_enabled=False, ) pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) decision = await mon._check_ai(pos, ltp=102.0, pnl_pct=2.0) assert decision is None @@ -286,18 +281,17 @@ class TestExecution: @pytest.mark.asyncio async def test_sell_uses_market_order( - self, config, mock_executor, + self, config, mock_executor, mock_market_data, ): """SELL signal should have entry_price set (not None) for P&L tracking.""" mon = _make_monitor( - mock_executor, MagicMock(), MagicMock(), config, ai_enabled=False, + mock_executor, mock_market_data, MagicMock(), config, ai_enabled=False, ) mon._router = MagicMock() mon._router._paper = MagicMock() pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) result = MonitorResult() ok = await mon._execute_sell(pos, ltp=95.0, reason="test stop", result=result) @@ -315,35 +309,31 @@ async def test_sell_uses_market_order( @pytest.mark.asyncio async def test_paper_mode_injects_quote( - self, config, mock_executor, + self, config, mock_executor, mock_market_data, ): - """In paper mode, a Quote should be injected before selling.""" + """In paper mode, a Quote should be fetched and injected before selling.""" mock_paper = MagicMock() mock_router = MagicMock() mock_router._paper = mock_paper mon = _make_monitor( - mock_executor, MagicMock(), mock_router, config, ai_enabled=False, + mock_executor, mock_market_data, mock_router, config, ai_enabled=False, ) - # Override router reference mon._router = mock_router pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) result = MonitorResult() await mon._execute_sell(pos, ltp=95.0, reason="test stop", result=result) - # Paper engine should have received a quote update + # MarketDataProvider should have been called + mock_market_data.get_quote.assert_called_with("TEST") + # Paper engine should have received the quote update mock_paper.update_quote.assert_called_once() - quote = mock_paper.update_quote.call_args[0][0] - assert isinstance(quote, Quote) - assert quote.symbol == "TEST" - assert quote.ltp == 95.0 @pytest.mark.asyncio - async def test_sell_failure_increments_failed_count(self, config): + async def test_sell_failure_increments_failed_count(self, config, mock_market_data): """Failed sell should increment sells_failed, not sells_executed.""" executor = AsyncMock() fail_result = MagicMock() @@ -352,14 +342,13 @@ async def test_sell_failure_increments_failed_count(self, config): executor.execute_signal = AsyncMock(return_value=fail_result) mon = _make_monitor( - executor, MagicMock(), MagicMock(), config, ai_enabled=False, + executor, mock_market_data, MagicMock(), config, ai_enabled=False, ) mon._router = MagicMock() mon._router._paper = MagicMock() pos = MonitoredPosition( - symbol="TEST", scrip_code="NSE_123", - entry_price=100.0, quantity=10, + symbol="TEST", entry_price=100.0, quantity=10, ) result = MonitorResult() ok = await mon._execute_sell(pos, ltp=95.0, reason="test", result=result) @@ -377,52 +366,43 @@ class TestMonitorRun: @pytest.mark.asyncio async def test_no_positions_exits_immediately( - self, config, mock_client, mock_executor, + self, config, mock_market_data, mock_executor, ): """Monitor should exit immediately if no positions are open.""" router = AsyncMock() router.get_positions = AsyncMock(return_value=[]) - # Patch resolve_scrip_code to avoid real API calls - with patch( - "skopaq.broker.scrip_resolver.resolve_scrip_code", - return_value="NSE_123", - ): - mon = _make_monitor( - mock_executor, mock_client, router, config, ai_enabled=False, - ) - result = await mon.run() + mon = _make_monitor( + mock_executor, mock_market_data, router, config, ai_enabled=False, + ) + result = await mon.run() assert result.positions_monitored == 0 assert result.sells_executed == 0 @pytest.mark.asyncio async def test_graceful_shutdown_on_stop_event( - self, config, mock_client, mock_router, mock_executor, + self, config, mock_market_data, mock_router, mock_executor, ): """Setting the stop event should cause the monitor to exit the loop.""" stop_event = asyncio.Event() - with patch( - "skopaq.broker.scrip_resolver.resolve_scrip_code", - return_value="NSE_123", - ): - mon = _make_monitor( - mock_executor, mock_client, mock_router, config, - stop_event=stop_event, ai_enabled=False, - ) + mon = _make_monitor( + mock_executor, mock_market_data, mock_router, config, + stop_event=stop_event, ai_enabled=False, + ) - # Set stop event after a short delay - async def _set_stop(): - await asyncio.sleep(0.1) - stop_event.set() + # Set stop event after a short delay + async def _set_stop(): + await asyncio.sleep(0.1) + stop_event.set() - # Patch safety to not trigger (so the loop runs until stopped) - with patch.object(mon, "_check_safety", return_value=None): - with patch.object(mon, "_should_eod_exit", return_value=False): - task = asyncio.create_task(_set_stop()) - result = await mon.run() - await task + # Patch safety to not trigger (so the loop runs until stopped) + with patch.object(mon, "_check_safety", return_value=None): + with patch.object(mon, "_should_eod_exit", return_value=False): + task = asyncio.create_task(_set_stop()) + result = await mon.run() + await task assert result.positions_monitored == 1 # Should not have sold (no safety trigger) @@ -432,36 +412,32 @@ async def test_zero_ltp_skips_position( self, config, mock_router, mock_executor, ): """Zero LTP should skip the position without crashing.""" - client = AsyncMock() - client.get_ltp = AsyncMock(return_value=0.0) + market_data = AsyncMock() + market_data.get_ltp = AsyncMock(return_value=0.0) stop_event = asyncio.Event() - with patch( - "skopaq.broker.scrip_resolver.resolve_scrip_code", - return_value="NSE_123", - ): - mon = _make_monitor( - mock_executor, client, mock_router, config, - stop_event=stop_event, ai_enabled=False, - ) + mon = _make_monitor( + mock_executor, market_data, mock_router, config, + stop_event=stop_event, ai_enabled=False, + ) - # Let it run 1 cycle then stop - async def _stop_after(): - await asyncio.sleep(0.2) - stop_event.set() + # Let it run 1 cycle then stop + async def _stop_after(): + await asyncio.sleep(0.2) + stop_event.set() - with patch.object(mon, "_should_eod_exit", return_value=False): - task = asyncio.create_task(_stop_after()) - result = await mon.run() - await task + with patch.object(mon, "_should_eod_exit", return_value=False): + task = asyncio.create_task(_stop_after()) + result = await mon.run() + await task # Should not have sold (zero LTP skipped) assert result.sells_executed == 0 @pytest.mark.asyncio async def test_safety_overrides_ai_hold( - self, config, mock_client, mock_router, mock_executor, mock_llm, + self, config, mock_market_data, mock_router, mock_executor, mock_llm, ): """Safety stop-loss should fire even if AI would say HOLD. @@ -469,31 +445,27 @@ async def test_safety_overrides_ai_hold( triggers, AI is never consulted. """ # LTP well below stop-loss - mock_client.get_ltp = AsyncMock(return_value=90.0) # 10% below entry + mock_market_data.get_ltp = AsyncMock(return_value=90.0) # 10% below entry stop_event = asyncio.Event() + mon = _make_monitor( + mock_executor, mock_market_data, mock_router, config, + llm=mock_llm, stop_event=stop_event, ai_enabled=True, + ) + + # AI would say HOLD — but safety should override with patch( - "skopaq.broker.scrip_resolver.resolve_scrip_code", - return_value="NSE_123", - ): - mon = _make_monitor( - mock_executor, mock_client, mock_router, config, - llm=mock_llm, stop_event=stop_event, ai_enabled=True, - ) + "skopaq.execution.position_monitor.analyze_exit", + return_value=SellDecision(action="HOLD", confidence=80), + ) as mock_ai: + with patch.object(mon, "_should_eod_exit", return_value=False): + result = await mon.run() - # AI would say HOLD — but safety should override - with patch( - "skopaq.execution.position_monitor.analyze_exit", - return_value=SellDecision(action="HOLD", confidence=80), - ) as mock_ai: - with patch.object(mon, "_should_eod_exit", return_value=False): - result = await mon.run() - - # Safety should have sold, AI should NOT have been called - assert result.sells_executed == 1 - assert "HARD STOP" in result.exit_reasons[0] - mock_ai.assert_not_called() + # Safety should have sold, AI should NOT have been called + assert result.sells_executed == 1 + assert "HARD STOP" in result.exit_reasons[0] + mock_ai.assert_not_called() # ── SellDecision Parsing Tests ───────────────────────────────────────────────