Add Groww live broker integration
This commit is contained in:
parent
d5fa17b30d
commit
28ec6c9a4d
@ -1,10 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from app.broker_store import (
|
from app.broker_store import (
|
||||||
clear_user_broker,
|
clear_user_broker,
|
||||||
|
expire_user_broker_session,
|
||||||
get_broker_credentials,
|
get_broker_credentials,
|
||||||
get_pending_broker,
|
get_pending_broker,
|
||||||
get_user_broker,
|
get_user_broker,
|
||||||
@ -13,9 +15,33 @@ from app.broker_store import (
|
|||||||
set_pending_broker,
|
set_pending_broker,
|
||||||
)
|
)
|
||||||
from app.services.auth_service import get_user_for_session
|
from app.services.auth_service import get_user_for_session
|
||||||
from app.services.zerodha_service import build_login_url, exchange_request_token
|
|
||||||
from app.services.email_service import send_email_async
|
from app.services.email_service import send_email_async
|
||||||
from app.services.zerodha_storage import set_session
|
from app.services.groww_service import (
|
||||||
|
GrowwApiError,
|
||||||
|
GrowwTokenError,
|
||||||
|
fetch_funds as fetch_groww_funds,
|
||||||
|
fetch_holdings as fetch_groww_holdings,
|
||||||
|
fetch_ltp as fetch_groww_ltp,
|
||||||
|
fetch_profile as fetch_groww_profile,
|
||||||
|
generate_access_token,
|
||||||
|
normalize_holding as normalize_groww_holding,
|
||||||
|
)
|
||||||
|
from app.services.groww_storage import get_session as get_groww_session
|
||||||
|
from app.services.live_equity_service import capture_live_equity_snapshot, get_live_equity_curve
|
||||||
|
from app.services.zerodha_service import (
|
||||||
|
KiteApiError,
|
||||||
|
KiteTokenError,
|
||||||
|
build_login_url,
|
||||||
|
exchange_request_token,
|
||||||
|
fetch_funds as fetch_zerodha_funds,
|
||||||
|
fetch_holdings as fetch_zerodha_holdings,
|
||||||
|
normalize_holding as normalize_zerodha_holding,
|
||||||
|
)
|
||||||
|
from app.services.zerodha_storage import (
|
||||||
|
clear_session as clear_zerodha_session,
|
||||||
|
get_session as get_zerodha_session,
|
||||||
|
set_session as set_zerodha_session,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/broker")
|
router = APIRouter(prefix="/api/broker")
|
||||||
|
|
||||||
@ -30,14 +56,226 @@ def _require_user(request: Request):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _first_number(*values, default: float = 0.0) -> float:
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_text(*values, default: str = "") -> str:
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_zerodha_broker_session(user_id: str):
|
||||||
|
expire_user_broker_session(user_id)
|
||||||
|
clear_zerodha_session(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_zerodha_error(user_id: str, exc: KiteApiError):
|
||||||
|
if isinstance(exc, KiteTokenError):
|
||||||
|
_clear_zerodha_broker_session(user_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Zerodha session expired. Please reconnect.",
|
||||||
|
) from exc
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_groww_error(user_id: str, exc: GrowwApiError):
|
||||||
|
if isinstance(exc, GrowwTokenError):
|
||||||
|
expire_user_broker_session(user_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Groww session expired. Please reconnect.",
|
||||||
|
) from exc
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_connected_broker(user_id: str):
|
||||||
|
entry = get_user_broker(user_id) or {}
|
||||||
|
broker_name = (entry.get("broker") or "").strip().upper()
|
||||||
|
if not entry.get("connected") or not broker_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Broker is not connected")
|
||||||
|
return entry, broker_name
|
||||||
|
|
||||||
|
|
||||||
|
def _groww_access_token(payload: dict | None) -> str:
|
||||||
|
entry = payload or {}
|
||||||
|
return _first_text(
|
||||||
|
entry.get("access_token"),
|
||||||
|
entry.get("accessToken"),
|
||||||
|
entry.get("token"),
|
||||||
|
entry.get("jwt_token"),
|
||||||
|
entry.get("jwtToken"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _groww_user_name(profile: dict | None) -> str | None:
|
||||||
|
value = _first_text(
|
||||||
|
(profile or {}).get("user_name"),
|
||||||
|
(profile or {}).get("full_name"),
|
||||||
|
(profile or {}).get("name"),
|
||||||
|
(profile or {}).get("display_name"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _groww_user_id(profile: dict | None) -> str | None:
|
||||||
|
value = _first_text(
|
||||||
|
(profile or {}).get("user_id"),
|
||||||
|
(profile or {}).get("client_id"),
|
||||||
|
(profile or {}).get("customer_id"),
|
||||||
|
(profile or {}).get("account_id"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _groww_holding_tradingsymbol(item: dict | None) -> str:
|
||||||
|
return _first_text(
|
||||||
|
(item or {}).get("tradingsymbol"),
|
||||||
|
(item or {}).get("trading_symbol"),
|
||||||
|
(item or {}).get("symbol"),
|
||||||
|
(item or {}).get("instrument_name"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _groww_holding_exchange(item: dict | None) -> str:
|
||||||
|
exchange = _first_text(
|
||||||
|
(item or {}).get("exchange"),
|
||||||
|
(item or {}).get("exchange_segment"),
|
||||||
|
(item or {}).get("exchange_name"),
|
||||||
|
default="NSE",
|
||||||
|
).upper()
|
||||||
|
if exchange in {"NSE", "BSE"}:
|
||||||
|
return exchange
|
||||||
|
if "BSE" in exchange:
|
||||||
|
return "BSE"
|
||||||
|
return "NSE"
|
||||||
|
|
||||||
|
|
||||||
|
def _groww_holding_segment(item: dict | None) -> str:
|
||||||
|
segment = _first_text(
|
||||||
|
(item or {}).get("segment"),
|
||||||
|
(item or {}).get("product_segment"),
|
||||||
|
default="CASH",
|
||||||
|
).upper()
|
||||||
|
return segment or "CASH"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_normalized_groww_holdings(access_token: str) -> list[dict]:
|
||||||
|
items = fetch_groww_holdings(access_token)
|
||||||
|
holdings: list[dict] = []
|
||||||
|
for item in items:
|
||||||
|
normalized = normalize_groww_holding(item)
|
||||||
|
tradingsymbol = _groww_holding_tradingsymbol(normalized)
|
||||||
|
exchange = _groww_holding_exchange(normalized)
|
||||||
|
segment = _groww_holding_segment(normalized)
|
||||||
|
if tradingsymbol and not normalized.get("last_price"):
|
||||||
|
try:
|
||||||
|
ltp_data = fetch_groww_ltp(
|
||||||
|
access_token,
|
||||||
|
exchange=exchange,
|
||||||
|
segment=segment,
|
||||||
|
trading_symbol=tradingsymbol,
|
||||||
|
)
|
||||||
|
normalized["last_price"] = _first_number(
|
||||||
|
ltp_data.get("ltp"),
|
||||||
|
ltp_data.get("last_price"),
|
||||||
|
ltp_data.get("price"),
|
||||||
|
normalized.get("last_price"),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
normalized["close_price"] = normalized["last_price"]
|
||||||
|
normalized["holding_value"] = normalized.get("effective_quantity", 0) * normalized["last_price"]
|
||||||
|
normalized["display_pnl"] = normalized.get("effective_quantity", 0) * (
|
||||||
|
normalized["last_price"] - normalized.get("average_price", 0)
|
||||||
|
)
|
||||||
|
except GrowwApiError:
|
||||||
|
pass
|
||||||
|
holdings.append(normalized)
|
||||||
|
return holdings
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_groww_funds(data: dict | None) -> dict:
|
||||||
|
payload = data if isinstance(data, dict) else {}
|
||||||
|
available = payload.get("available") if isinstance(payload.get("available"), dict) else {}
|
||||||
|
equity = payload.get("equity") if isinstance(payload.get("equity"), dict) else {}
|
||||||
|
equity_available = equity.get("available") if isinstance(equity.get("available"), dict) else {}
|
||||||
|
|
||||||
|
cash = _first_number(
|
||||||
|
payload.get("cash"),
|
||||||
|
payload.get("available_cash"),
|
||||||
|
payload.get("available_balance"),
|
||||||
|
available.get("cash"),
|
||||||
|
available.get("available_cash"),
|
||||||
|
available.get("balance"),
|
||||||
|
equity.get("cash"),
|
||||||
|
equity_available.get("cash"),
|
||||||
|
equity_available.get("live_balance"),
|
||||||
|
)
|
||||||
|
net = _first_number(
|
||||||
|
payload.get("net"),
|
||||||
|
payload.get("total"),
|
||||||
|
payload.get("margin_available"),
|
||||||
|
equity.get("net"),
|
||||||
|
cash,
|
||||||
|
)
|
||||||
|
withdrawable = _first_number(
|
||||||
|
payload.get("withdrawable"),
|
||||||
|
payload.get("available_to_withdraw"),
|
||||||
|
available.get("withdrawable"),
|
||||||
|
cash,
|
||||||
|
)
|
||||||
|
balance = _first_number(
|
||||||
|
payload.get("balance"),
|
||||||
|
payload.get("available_balance"),
|
||||||
|
available.get("balance"),
|
||||||
|
cash,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"net": net,
|
||||||
|
"cash": cash,
|
||||||
|
"withdrawable": withdrawable,
|
||||||
|
"balance": balance,
|
||||||
|
"available": {
|
||||||
|
"live_balance": cash,
|
||||||
|
"cash": cash,
|
||||||
|
"opening_balance": balance,
|
||||||
|
},
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_saved_broker_login_url(
|
def _build_saved_broker_login_url(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
redirect_url_override: str | None = None,
|
redirect_url_override: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
entry = get_user_broker(user_id) or {}
|
||||||
|
broker_name = (entry.get("broker") or "").strip().upper()
|
||||||
|
if broker_name and broker_name != "ZERODHA":
|
||||||
|
raise HTTPException(status_code=400, detail="Saved login is only available for Zerodha")
|
||||||
|
|
||||||
creds = get_broker_credentials(user_id)
|
creds = get_broker_credentials(user_id)
|
||||||
if not creds:
|
if not creds:
|
||||||
raise HTTPException(status_code=400, detail="Broker credentials not configured")
|
raise HTTPException(status_code=400, detail="Broker credentials not configured")
|
||||||
|
|
||||||
redirect_url = (redirect_url_override or os.getenv("ZERODHA_REDIRECT_URL") or "").strip()
|
redirect_url = (redirect_url_override or os.getenv("ZERODHA_REDIRECT_URL") or "").strip()
|
||||||
if not redirect_url:
|
if not redirect_url:
|
||||||
base = str(request.base_url).rstrip("/")
|
base = str(request.base_url).rstrip("/")
|
||||||
@ -45,6 +283,18 @@ def _build_saved_broker_login_url(
|
|||||||
return build_login_url(creds["api_key"], redirect_url=redirect_url)
|
return build_login_url(creds["api_key"], redirect_url=redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_broker_connected(username: str, broker: str, broker_user_id: str | None):
|
||||||
|
try:
|
||||||
|
body = (
|
||||||
|
"Your broker has been connected to Quantfortune.\n\n"
|
||||||
|
f"Broker: {broker}\n"
|
||||||
|
f"Broker User ID: {broker_user_id or 'N/A'}\n"
|
||||||
|
)
|
||||||
|
send_email_async(username, "Broker connected", body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.post("/connect")
|
@router.post("/connect")
|
||||||
async def connect_broker(payload: dict, request: Request):
|
async def connect_broker(payload: dict, request: Request):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
@ -62,15 +312,7 @@ async def connect_broker(payload: dict, request: Request):
|
|||||||
user_name=user_name or None,
|
user_name=user_name or None,
|
||||||
broker_user_id=broker_user_id or None,
|
broker_user_id=broker_user_id or None,
|
||||||
)
|
)
|
||||||
try:
|
_notify_broker_connected(user["username"], broker, broker_user_id or None)
|
||||||
body = (
|
|
||||||
"Your broker has been connected to Quantfortune.\n\n"
|
|
||||||
f"Broker: {broker}\n"
|
|
||||||
f"Broker User ID: {broker_user_id or 'N/A'}\n"
|
|
||||||
)
|
|
||||||
send_email_async(user["username"], "Broker connected", body)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"connected": True}
|
return {"connected": True}
|
||||||
|
|
||||||
|
|
||||||
@ -94,6 +336,7 @@ async def broker_status(request: Request):
|
|||||||
async def disconnect_broker(request: Request):
|
async def disconnect_broker(request: Request):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
clear_user_broker(user["id"])
|
clear_user_broker(user["id"])
|
||||||
|
clear_zerodha_session(user["id"])
|
||||||
set_broker_auth_state(user["id"], "DISCONNECTED")
|
set_broker_auth_state(user["id"], "DISCONNECTED")
|
||||||
try:
|
try:
|
||||||
body = "Your broker connection has been disconnected from Quantfortune."
|
body = "Your broker connection has been disconnected from Quantfortune."
|
||||||
@ -116,6 +359,84 @@ async def zerodha_login(payload: dict, request: Request):
|
|||||||
return {"loginUrl": build_login_url(api_key, redirect_url=redirect_url or None)}
|
return {"loginUrl": build_login_url(api_key, redirect_url=redirect_url or None)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/groww/connect")
|
||||||
|
async def groww_connect(payload: dict, request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
api_key = (payload.get("apiKey") or "").strip()
|
||||||
|
api_secret = (payload.get("apiSecret") or "").strip()
|
||||||
|
if not api_key or not api_secret:
|
||||||
|
raise HTTPException(status_code=400, detail="API key and secret are required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_payload = generate_access_token(api_key, api_secret)
|
||||||
|
access_token = _groww_access_token(token_payload)
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=502, detail="Groww did not return an access token")
|
||||||
|
profile = fetch_groww_profile(access_token)
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
_raise_groww_error(user["id"], exc)
|
||||||
|
|
||||||
|
user_name = _groww_user_name(profile)
|
||||||
|
broker_user_id = _groww_user_id(profile)
|
||||||
|
set_connected_broker(
|
||||||
|
user["id"],
|
||||||
|
"GROWW",
|
||||||
|
access_token,
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
user_name=user_name,
|
||||||
|
broker_user_id=broker_user_id,
|
||||||
|
auth_state="VALID",
|
||||||
|
)
|
||||||
|
_notify_broker_connected(user["username"], "GROWW", broker_user_id)
|
||||||
|
return {
|
||||||
|
"connected": True,
|
||||||
|
"broker": "GROWW",
|
||||||
|
"userName": user_name,
|
||||||
|
"brokerUserId": broker_user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/groww/reconnect")
|
||||||
|
async def groww_reconnect(request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
entry = get_user_broker(user["id"]) or {}
|
||||||
|
if (entry.get("broker") or "").strip().upper() not in {"", "GROWW"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Current broker is not Groww")
|
||||||
|
|
||||||
|
creds = get_broker_credentials(user["id"])
|
||||||
|
if not creds:
|
||||||
|
raise HTTPException(status_code=400, detail="Broker credentials not configured")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_payload = generate_access_token(creds["api_key"], creds["api_secret"])
|
||||||
|
access_token = _groww_access_token(token_payload)
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=502, detail="Groww did not return an access token")
|
||||||
|
profile = fetch_groww_profile(access_token)
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
_raise_groww_error(user["id"], exc)
|
||||||
|
|
||||||
|
user_name = _groww_user_name(profile) or entry.get("user_name")
|
||||||
|
broker_user_id = _groww_user_id(profile) or entry.get("broker_user_id")
|
||||||
|
set_connected_broker(
|
||||||
|
user["id"],
|
||||||
|
"GROWW",
|
||||||
|
access_token,
|
||||||
|
api_key=creds["api_key"],
|
||||||
|
api_secret=creds["api_secret"],
|
||||||
|
user_name=user_name,
|
||||||
|
broker_user_id=broker_user_id,
|
||||||
|
auth_state="VALID",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"connected": True,
|
||||||
|
"broker": "GROWW",
|
||||||
|
"userName": user_name,
|
||||||
|
"brokerUserId": broker_user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/zerodha/callback")
|
@router.get("/zerodha/callback")
|
||||||
async def zerodha_callback(request: Request, request_token: str = ""):
|
async def zerodha_callback(request: Request, request_token: str = ""):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
@ -138,7 +459,7 @@ async def zerodha_callback(request: Request, request_token: str = ""):
|
|||||||
if not access_token:
|
if not access_token:
|
||||||
raise HTTPException(status_code=400, detail="Missing access token from Zerodha")
|
raise HTTPException(status_code=400, detail="Missing access token from Zerodha")
|
||||||
|
|
||||||
saved = set_session(
|
saved = set_zerodha_session(
|
||||||
user["id"],
|
user["id"],
|
||||||
{
|
{
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
@ -205,7 +526,7 @@ async def broker_callback(request: Request, request_token: str = ""):
|
|||||||
if not access_token:
|
if not access_token:
|
||||||
raise HTTPException(status_code=400, detail="Missing access token from Zerodha")
|
raise HTTPException(status_code=400, detail="Missing access token from Zerodha")
|
||||||
|
|
||||||
set_session(
|
set_zerodha_session(
|
||||||
user["id"],
|
user["id"],
|
||||||
{
|
{
|
||||||
"api_key": creds["api_key"],
|
"api_key": creds["api_key"],
|
||||||
@ -227,3 +548,108 @@ async def broker_callback(request: Request, request_token: str = ""):
|
|||||||
)
|
)
|
||||||
target_url = os.getenv("BROKER_DASHBOARD_URL") or "/dashboard?armed=false"
|
target_url = os.getenv("BROKER_DASHBOARD_URL") or "/dashboard?armed=false"
|
||||||
return RedirectResponse(target_url)
|
return RedirectResponse(target_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/holdings")
|
||||||
|
async def broker_holdings(request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
_entry, broker_name = _resolve_connected_broker(user["id"])
|
||||||
|
if broker_name == "ZERODHA":
|
||||||
|
session = get_zerodha_session(user["id"])
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=400, detail="Zerodha is not connected")
|
||||||
|
try:
|
||||||
|
data = fetch_zerodha_holdings(session["api_key"], session["access_token"])
|
||||||
|
except KiteApiError as exc:
|
||||||
|
_raise_zerodha_error(user["id"], exc)
|
||||||
|
return {"broker": broker_name, "holdings": [normalize_zerodha_holding(item) for item in data]}
|
||||||
|
|
||||||
|
if broker_name == "GROWW":
|
||||||
|
session = get_groww_session(user["id"])
|
||||||
|
if not session or not session.get("access_token"):
|
||||||
|
raise HTTPException(status_code=400, detail="Groww is not connected")
|
||||||
|
try:
|
||||||
|
holdings = _fetch_normalized_groww_holdings(session["access_token"])
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
_raise_groww_error(user["id"], exc)
|
||||||
|
return {"broker": broker_name, "holdings": holdings}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported broker: {broker_name}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/funds")
|
||||||
|
async def broker_funds(request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
_entry, broker_name = _resolve_connected_broker(user["id"])
|
||||||
|
if broker_name == "ZERODHA":
|
||||||
|
session = get_zerodha_session(user["id"])
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=400, detail="Zerodha is not connected")
|
||||||
|
try:
|
||||||
|
data = fetch_zerodha_funds(session["api_key"], session["access_token"])
|
||||||
|
except KiteApiError as exc:
|
||||||
|
_raise_zerodha_error(user["id"], exc)
|
||||||
|
equity = data.get("equity", {}) if isinstance(data, dict) else {}
|
||||||
|
return {"broker": broker_name, "funds": {**equity, "raw": data}}
|
||||||
|
|
||||||
|
if broker_name == "GROWW":
|
||||||
|
session = get_groww_session(user["id"])
|
||||||
|
if not session or not session.get("access_token"):
|
||||||
|
raise HTTPException(status_code=400, detail="Groww is not connected")
|
||||||
|
try:
|
||||||
|
data = fetch_groww_funds(session["access_token"])
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
_raise_groww_error(user["id"], exc)
|
||||||
|
return {"broker": broker_name, "funds": _normalize_groww_funds(data)}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported broker: {broker_name}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/equity-curve")
|
||||||
|
async def broker_equity_curve(request: Request, from_: str = Query("", alias="from")):
|
||||||
|
user = _require_user(request)
|
||||||
|
_entry, broker_name = _resolve_connected_broker(user["id"])
|
||||||
|
|
||||||
|
if broker_name == "ZERODHA":
|
||||||
|
session = get_zerodha_session(user["id"])
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=400, detail="Zerodha is not connected")
|
||||||
|
try:
|
||||||
|
holdings = [
|
||||||
|
normalize_zerodha_holding(item)
|
||||||
|
for item in fetch_zerodha_holdings(session["api_key"], session["access_token"])
|
||||||
|
]
|
||||||
|
raw_funds = fetch_zerodha_funds(session["api_key"], session["access_token"])
|
||||||
|
funds_data = {**(raw_funds.get("equity", {}) or {}), "raw": raw_funds}
|
||||||
|
except KiteApiError as exc:
|
||||||
|
_raise_zerodha_error(user["id"], exc)
|
||||||
|
elif broker_name == "GROWW":
|
||||||
|
session = get_groww_session(user["id"])
|
||||||
|
if not session or not session.get("access_token"):
|
||||||
|
raise HTTPException(status_code=400, detail="Groww is not connected")
|
||||||
|
try:
|
||||||
|
holdings = _fetch_normalized_groww_holdings(session["access_token"])
|
||||||
|
funds_data = _normalize_groww_funds(fetch_groww_funds(session["access_token"]))
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
_raise_groww_error(user["id"], exc)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported broker: {broker_name}")
|
||||||
|
|
||||||
|
capture_live_equity_snapshot(
|
||||||
|
user["id"],
|
||||||
|
holdings=holdings,
|
||||||
|
funds_data=funds_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
default_start = (now - timedelta(days=90)).date()
|
||||||
|
if from_:
|
||||||
|
try:
|
||||||
|
start_date = datetime.fromisoformat(from_).date()
|
||||||
|
except ValueError:
|
||||||
|
start_date = default_start
|
||||||
|
else:
|
||||||
|
start_date = default_start
|
||||||
|
if start_date > now.date():
|
||||||
|
start_date = now.date()
|
||||||
|
return get_live_equity_curve(user["id"], start_date=start_date)
|
||||||
|
|||||||
355
backend/app/services/groww_service.py
Normal file
355
backend/app/services/groww_service.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
GROWW_API_BASE = os.getenv("GROWW_API_BASE", "https://api.groww.in").rstrip("/")
|
||||||
|
GROWW_API_VERSION = os.getenv("GROWW_API_VERSION", "1.0")
|
||||||
|
|
||||||
|
|
||||||
|
class GrowwApiError(Exception):
|
||||||
|
def __init__(self, status_code: int, error_type: str, message: str):
|
||||||
|
super().__init__(f"Groww API error {status_code}: {error_type} - {message}")
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error_type = error_type
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class GrowwTokenError(GrowwApiError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GrowwPermissionError(GrowwApiError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _json_headers(extra: dict | None = None) -> dict:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-VERSION": GROWW_API_VERSION,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
headers.update(extra)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
data: dict | None = None,
|
||||||
|
headers: dict | None = None,
|
||||||
|
):
|
||||||
|
payload = None
|
||||||
|
if data is not None:
|
||||||
|
payload = json.dumps(data).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers=headers or {},
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
error_body = err.read().decode("utf-8") if err.fp else ""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(error_body) if error_body else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = {}
|
||||||
|
|
||||||
|
error = parsed.get("error") if isinstance(parsed.get("error"), dict) else {}
|
||||||
|
error_type = (
|
||||||
|
error.get("code")
|
||||||
|
or parsed.get("error_code")
|
||||||
|
or parsed.get("error_type")
|
||||||
|
or parsed.get("status")
|
||||||
|
or "unknown_error"
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
error.get("message")
|
||||||
|
or parsed.get("message")
|
||||||
|
or parsed.get("detail")
|
||||||
|
or error_body
|
||||||
|
or err.reason
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_error = str(error_type).strip().lower()
|
||||||
|
exc_cls = GrowwApiError
|
||||||
|
if err.code in {401, 403} or "token" in normalized_error or "auth" in normalized_error:
|
||||||
|
exc_cls = GrowwTokenError
|
||||||
|
elif "permission" in normalized_error:
|
||||||
|
exc_cls = GrowwPermissionError
|
||||||
|
raise exc_cls(err.code, str(error_type), str(message)) from err
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
return {}
|
||||||
|
return json.loads(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_data(payload: dict | None):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
data = payload.get("data")
|
||||||
|
return data if data is not None else payload
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(access_token: str) -> dict:
|
||||||
|
return _json_headers({"Authorization": f"Bearer {access_token}"})
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key_headers(api_key: str) -> dict:
|
||||||
|
return _json_headers({"Authorization": f"Bearer {api_key}"})
|
||||||
|
|
||||||
|
|
||||||
|
def _single_query_url(path: str, **params) -> str:
|
||||||
|
query = urllib.parse.urlencode(
|
||||||
|
[(key, value) for key, value in params.items() if value is not None and value != ""]
|
||||||
|
)
|
||||||
|
if query:
|
||||||
|
return f"{GROWW_API_BASE}{path}?{query}"
|
||||||
|
return f"{GROWW_API_BASE}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_access_token(api_key: str, api_secret: str) -> dict:
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
checksum = hashlib.sha256(f"{api_secret}{timestamp}".encode("utf-8")).hexdigest()
|
||||||
|
response = _request(
|
||||||
|
"POST",
|
||||||
|
f"{GROWW_API_BASE}/v1/token/api/access",
|
||||||
|
data={
|
||||||
|
"key_type": "approval",
|
||||||
|
"checksum": checksum,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
},
|
||||||
|
headers=_api_key_headers(api_key),
|
||||||
|
)
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_profile(access_token: str) -> dict:
|
||||||
|
response = _request(
|
||||||
|
"GET",
|
||||||
|
f"{GROWW_API_BASE}/v1/user/detail",
|
||||||
|
headers=_auth_headers(access_token),
|
||||||
|
)
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_holdings(access_token: str) -> list:
|
||||||
|
response = _request(
|
||||||
|
"GET",
|
||||||
|
f"{GROWW_API_BASE}/v1/holdings/user",
|
||||||
|
headers=_auth_headers(access_token),
|
||||||
|
)
|
||||||
|
data = _first_data(response)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("holdings", "items", "records"):
|
||||||
|
if isinstance(data.get(key), list):
|
||||||
|
return data[key]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_positions(access_token: str) -> list:
|
||||||
|
response = _request(
|
||||||
|
"GET",
|
||||||
|
f"{GROWW_API_BASE}/v1/positions/user",
|
||||||
|
headers=_auth_headers(access_token),
|
||||||
|
)
|
||||||
|
data = _first_data(response)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("positions", "items", "records"):
|
||||||
|
if isinstance(data.get(key), list):
|
||||||
|
return data[key]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_funds(access_token: str) -> dict:
|
||||||
|
response = _request(
|
||||||
|
"GET",
|
||||||
|
f"{GROWW_API_BASE}/v1/margins/detail/user",
|
||||||
|
headers=_auth_headers(access_token),
|
||||||
|
)
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_ltp(access_token: str, *, exchange: str, segment: str, trading_symbol: str) -> dict:
|
||||||
|
url = _single_query_url(
|
||||||
|
"/v1/live-data/ltp",
|
||||||
|
exchange=exchange,
|
||||||
|
segment=segment,
|
||||||
|
trading_symbol=trading_symbol,
|
||||||
|
)
|
||||||
|
response = _request("GET", url, headers=_auth_headers(access_token))
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def place_order(
|
||||||
|
access_token: str,
|
||||||
|
*,
|
||||||
|
trading_symbol: str,
|
||||||
|
exchange: str,
|
||||||
|
segment: str,
|
||||||
|
transaction_type: str,
|
||||||
|
order_type: str,
|
||||||
|
quantity: int,
|
||||||
|
product: str,
|
||||||
|
validity: str = "DAY",
|
||||||
|
price: float | None = None,
|
||||||
|
trigger_price: float | None = None,
|
||||||
|
order_reference_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
payload = {
|
||||||
|
"trading_symbol": trading_symbol,
|
||||||
|
"quantity": int(quantity),
|
||||||
|
"validity": validity,
|
||||||
|
"exchange": exchange,
|
||||||
|
"segment": segment,
|
||||||
|
"product": product,
|
||||||
|
"order_type": order_type,
|
||||||
|
"transaction_type": transaction_type,
|
||||||
|
}
|
||||||
|
if price is not None:
|
||||||
|
payload["price"] = float(price)
|
||||||
|
if trigger_price is not None:
|
||||||
|
payload["trigger_price"] = float(trigger_price)
|
||||||
|
if order_reference_id:
|
||||||
|
payload["order_reference_id"] = order_reference_id
|
||||||
|
|
||||||
|
response = _request(
|
||||||
|
"POST",
|
||||||
|
f"{GROWW_API_BASE}/v1/order/create",
|
||||||
|
data=payload,
|
||||||
|
headers=_auth_headers(access_token),
|
||||||
|
)
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_order_status(access_token: str, groww_order_id: str, *, segment: str = "CASH") -> dict:
|
||||||
|
url = _single_query_url(
|
||||||
|
f"/v1/order/status/{urllib.parse.quote(str(groww_order_id).strip())}",
|
||||||
|
segment=segment,
|
||||||
|
)
|
||||||
|
response = _request("GET", url, headers=_auth_headers(access_token))
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_order_detail(access_token: str, groww_order_id: str, *, segment: str = "CASH") -> dict:
|
||||||
|
url = _single_query_url(
|
||||||
|
f"/v1/order/detail/{urllib.parse.quote(str(groww_order_id).strip())}",
|
||||||
|
segment=segment,
|
||||||
|
)
|
||||||
|
response = _request("GET", url, headers=_auth_headers(access_token))
|
||||||
|
return _first_data(response) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_orders(access_token: str, *, segment: str = "CASH") -> list:
|
||||||
|
url = _single_query_url("/v1/order/list", segment=segment)
|
||||||
|
response = _request("GET", url, headers=_auth_headers(access_token))
|
||||||
|
data = _first_data(response)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("orders", "items", "records"):
|
||||||
|
if isinstance(data.get(key), list):
|
||||||
|
return data[key]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _first_float(*values, default: float = 0.0) -> float:
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_text(*values, default: str = "") -> str:
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def holding_quantity(item: dict | None) -> float:
|
||||||
|
entry = item or {}
|
||||||
|
return _first_float(
|
||||||
|
entry.get("quantity"),
|
||||||
|
entry.get("available_quantity"),
|
||||||
|
entry.get("net_quantity"),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def holding_average_price(item: dict | None) -> float:
|
||||||
|
entry = item or {}
|
||||||
|
return _first_float(entry.get("average_price"), entry.get("avg_price"), default=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def holding_last_price(item: dict | None) -> float:
|
||||||
|
entry = item or {}
|
||||||
|
return _first_float(
|
||||||
|
entry.get("last_price"),
|
||||||
|
entry.get("ltp"),
|
||||||
|
entry.get("close_price"),
|
||||||
|
entry.get("average_price"),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_holding(item: dict | None) -> dict:
|
||||||
|
entry = dict(item or {})
|
||||||
|
quantity = holding_quantity(entry)
|
||||||
|
average_price = holding_average_price(entry)
|
||||||
|
last_price = holding_last_price(entry)
|
||||||
|
tradingsymbol = _first_text(
|
||||||
|
entry.get("trading_symbol"),
|
||||||
|
entry.get("tradingsymbol"),
|
||||||
|
entry.get("symbol"),
|
||||||
|
entry.get("instrument_name"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
exchange = _first_text(
|
||||||
|
entry.get("exchange"),
|
||||||
|
entry.get("exchange_segment"),
|
||||||
|
entry.get("exchange_name"),
|
||||||
|
default="NSE",
|
||||||
|
).upper()
|
||||||
|
segment = _first_text(entry.get("segment"), entry.get("product_segment"), default="CASH").upper()
|
||||||
|
symbol = tradingsymbol
|
||||||
|
if tradingsymbol and not tradingsymbol.endswith((".NS", ".BO")):
|
||||||
|
if exchange == "NSE":
|
||||||
|
symbol = f"{tradingsymbol}.NS"
|
||||||
|
elif exchange == "BSE":
|
||||||
|
symbol = f"{tradingsymbol}.BO"
|
||||||
|
entry["settled_quantity"] = quantity
|
||||||
|
entry["t1_quantity"] = 0.0
|
||||||
|
entry["effective_quantity"] = quantity
|
||||||
|
entry["quantity"] = quantity
|
||||||
|
entry["average_price"] = average_price
|
||||||
|
entry["last_price"] = last_price
|
||||||
|
entry["close_price"] = last_price
|
||||||
|
entry["exchange"] = exchange
|
||||||
|
entry["segment"] = segment
|
||||||
|
entry["tradingsymbol"] = tradingsymbol
|
||||||
|
entry["symbol"] = symbol
|
||||||
|
entry["display_pnl"] = quantity * (last_price - average_price)
|
||||||
|
entry["holding_value"] = quantity * last_price
|
||||||
|
return entry
|
||||||
30
backend/app/services/groww_storage.py
Normal file
30
backend/app/services/groww_storage.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from app.services.crypto_service import decrypt_value
|
||||||
|
from app.services.db import db_transaction
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(user_id: str):
|
||||||
|
with db_transaction() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT broker, connected, access_token, api_key, user_name, broker_user_id, connected_at
|
||||||
|
FROM user_broker
|
||||||
|
WHERE user_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
broker, connected, access_token, api_key, user_name, broker_user_id, connected_at = row
|
||||||
|
if not connected or not access_token:
|
||||||
|
return None
|
||||||
|
if (broker or "").strip().upper() != "GROWW":
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"api_key": api_key,
|
||||||
|
"access_token": decrypt_value(access_token),
|
||||||
|
"user_name": user_name,
|
||||||
|
"broker_user_id": broker_user_id,
|
||||||
|
"linked_at": connected_at,
|
||||||
|
}
|
||||||
@ -5,15 +5,24 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.broker_store import get_user_broker
|
||||||
from app.services.db import db_connection
|
from app.services.db import db_connection
|
||||||
|
from app.services.groww_service import (
|
||||||
|
GrowwApiError,
|
||||||
|
fetch_funds as fetch_groww_funds,
|
||||||
|
fetch_holdings as fetch_groww_holdings,
|
||||||
|
normalize_holding as normalize_groww_holding,
|
||||||
|
)
|
||||||
|
from app.services.groww_storage import get_session as get_groww_session
|
||||||
from app.services.zerodha_service import (
|
from app.services.zerodha_service import (
|
||||||
KiteApiError,
|
KiteApiError,
|
||||||
fetch_funds,
|
fetch_funds as fetch_zerodha_funds,
|
||||||
fetch_holdings,
|
fetch_holdings as fetch_zerodha_holdings,
|
||||||
holding_effective_quantity,
|
holding_effective_quantity,
|
||||||
holding_last_price,
|
holding_last_price,
|
||||||
|
normalize_holding as normalize_zerodha_holding,
|
||||||
)
|
)
|
||||||
from app.services.zerodha_storage import get_session
|
from app.services.zerodha_storage import get_session as get_zerodha_session
|
||||||
|
|
||||||
IST = ZoneInfo("Asia/Calcutta")
|
IST = ZoneInfo("Asia/Calcutta")
|
||||||
AUTO_SNAPSHOT_AFTER_HOUR = int(os.getenv("LIVE_EQUITY_SNAPSHOT_HOUR", "15"))
|
AUTO_SNAPSHOT_AFTER_HOUR = int(os.getenv("LIVE_EQUITY_SNAPSHOT_HOUR", "15"))
|
||||||
@ -72,6 +81,57 @@ def _extract_holdings_value(holdings: list[dict] | None) -> float:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_groww_funds(data: dict | None) -> dict:
|
||||||
|
payload = data if isinstance(data, dict) else {}
|
||||||
|
available = payload.get("available") if isinstance(payload.get("available"), dict) else {}
|
||||||
|
equity = payload.get("equity") if isinstance(payload.get("equity"), dict) else {}
|
||||||
|
equity_available = equity.get("available") if isinstance(equity.get("available"), dict) else {}
|
||||||
|
|
||||||
|
cash = _first_numeric(
|
||||||
|
payload.get("cash"),
|
||||||
|
payload.get("available_cash"),
|
||||||
|
payload.get("available_balance"),
|
||||||
|
available.get("cash"),
|
||||||
|
available.get("available_cash"),
|
||||||
|
available.get("balance"),
|
||||||
|
equity.get("cash"),
|
||||||
|
equity_available.get("cash"),
|
||||||
|
equity_available.get("live_balance"),
|
||||||
|
)
|
||||||
|
net = _first_numeric(
|
||||||
|
payload.get("net"),
|
||||||
|
payload.get("total"),
|
||||||
|
payload.get("margin_available"),
|
||||||
|
equity.get("net"),
|
||||||
|
cash,
|
||||||
|
)
|
||||||
|
withdrawable = _first_numeric(
|
||||||
|
payload.get("withdrawable"),
|
||||||
|
payload.get("available_to_withdraw"),
|
||||||
|
available.get("withdrawable"),
|
||||||
|
cash,
|
||||||
|
)
|
||||||
|
balance = _first_numeric(
|
||||||
|
payload.get("balance"),
|
||||||
|
payload.get("available_balance"),
|
||||||
|
available.get("balance"),
|
||||||
|
cash,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"net": net,
|
||||||
|
"cash": cash,
|
||||||
|
"withdrawable": withdrawable,
|
||||||
|
"balance": balance,
|
||||||
|
"available": {
|
||||||
|
"live_balance": cash,
|
||||||
|
"cash": cash,
|
||||||
|
"opening_balance": balance,
|
||||||
|
},
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _upsert_snapshot(
|
def _upsert_snapshot(
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@ -126,15 +186,44 @@ def capture_live_equity_snapshot(
|
|||||||
funds_data: dict | None = None,
|
funds_data: dict | None = None,
|
||||||
captured_at: datetime | None = None,
|
captured_at: datetime | None = None,
|
||||||
):
|
):
|
||||||
session = get_session(user_id)
|
broker_state = get_user_broker(user_id) or {}
|
||||||
if not session:
|
broker_name = (broker_state.get("broker") or "").strip().upper()
|
||||||
return None
|
|
||||||
|
|
||||||
captured_at = captured_at or _now_utc()
|
captured_at = captured_at or _now_utc()
|
||||||
if holdings is None:
|
if holdings is None:
|
||||||
holdings = fetch_holdings(session["api_key"], session["access_token"])
|
if broker_name == "ZERODHA":
|
||||||
|
session = get_zerodha_session(user_id)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
holdings = [
|
||||||
|
normalize_zerodha_holding(item)
|
||||||
|
for item in fetch_zerodha_holdings(session["api_key"], session["access_token"])
|
||||||
|
]
|
||||||
|
elif broker_name == "GROWW":
|
||||||
|
session = get_groww_session(user_id)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
holdings = [
|
||||||
|
normalize_groww_holding(item)
|
||||||
|
for item in fetch_groww_holdings(session["access_token"])
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if funds_data is None:
|
if funds_data is None:
|
||||||
funds_data = fetch_funds(session["api_key"], session["access_token"])
|
if broker_name == "ZERODHA":
|
||||||
|
session = get_zerodha_session(user_id)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
raw_funds = fetch_zerodha_funds(session["api_key"], session["access_token"])
|
||||||
|
equity = raw_funds.get("equity", {}) if isinstance(raw_funds, dict) else {}
|
||||||
|
funds_data = {**equity, "raw": raw_funds}
|
||||||
|
elif broker_name == "GROWW":
|
||||||
|
session = get_groww_session(user_id)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
funds_data = _normalize_groww_funds(fetch_groww_funds(session["access_token"]))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
cash_value = _extract_cash_value(funds_data)
|
cash_value = _extract_cash_value(funds_data)
|
||||||
holdings_value = _extract_holdings_value(holdings)
|
holdings_value = _extract_holdings_value(holdings)
|
||||||
@ -187,18 +276,18 @@ def get_live_equity_curve(user_id: str, *, start_date: date | None = None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _list_connected_zerodha_users() -> list[str]:
|
def _list_connected_live_brokers() -> list[tuple[str, str]]:
|
||||||
with db_connection() as conn:
|
with db_connection() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT user_id
|
SELECT user_id, UPPER(COALESCE(broker, ''))
|
||||||
FROM user_broker
|
FROM user_broker
|
||||||
WHERE connected = TRUE
|
WHERE connected = TRUE
|
||||||
AND UPPER(COALESCE(broker, '')) = 'ZERODHA'
|
AND UPPER(COALESCE(broker, '')) IN ('ZERODHA', 'GROWW')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return [row[0] for row in cur.fetchall()]
|
return [(row[0], row[1]) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def _should_auto_snapshot(now_local: datetime) -> bool:
|
def _should_auto_snapshot(now_local: datetime) -> bool:
|
||||||
@ -222,11 +311,13 @@ def _run_auto_snapshot_cycle():
|
|||||||
if not _should_auto_snapshot(now_local):
|
if not _should_auto_snapshot(now_local):
|
||||||
return
|
return
|
||||||
|
|
||||||
for user_id in _list_connected_zerodha_users():
|
for user_id, _broker_name in _list_connected_live_brokers():
|
||||||
try:
|
try:
|
||||||
capture_live_equity_snapshot(user_id)
|
capture_live_equity_snapshot(user_id)
|
||||||
except KiteApiError:
|
except KiteApiError:
|
||||||
continue
|
continue
|
||||||
|
except GrowwApiError:
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@ -27,11 +27,13 @@ from app.services.run_service import (
|
|||||||
)
|
)
|
||||||
from app.services.auth_service import get_user_by_id
|
from app.services.auth_service import get_user_by_id
|
||||||
from app.services.email_service import send_email_async
|
from app.services.email_service import send_email_async
|
||||||
|
from app.services.groww_service import GrowwApiError, GrowwTokenError, fetch_funds as fetch_groww_funds
|
||||||
|
from app.services.groww_storage import get_session as get_groww_session
|
||||||
from app.services.zerodha_service import (
|
from app.services.zerodha_service import (
|
||||||
KiteTokenError,
|
KiteTokenError,
|
||||||
fetch_funds,
|
fetch_funds as fetch_zerodha_funds,
|
||||||
)
|
)
|
||||||
from app.services.zerodha_storage import get_session
|
from app.services.zerodha_storage import get_session as get_zerodha_session
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
from psycopg2 import errors
|
from psycopg2 import errors
|
||||||
|
|
||||||
@ -327,13 +329,44 @@ def validate_frequency(freq: dict, mode: str):
|
|||||||
def _validate_live_broker_session(user_id: str):
|
def _validate_live_broker_session(user_id: str):
|
||||||
broker_state = get_user_broker(user_id) or {}
|
broker_state = get_user_broker(user_id) or {}
|
||||||
broker_name = (broker_state.get("broker") or "").strip().upper()
|
broker_name = (broker_state.get("broker") or "").strip().upper()
|
||||||
if not broker_state.get("connected") or broker_name != "ZERODHA":
|
if not broker_state.get("connected") or broker_name not in {"ZERODHA", "GROWW"}:
|
||||||
return False, broker_state, "broker_not_connected"
|
return False, broker_state, "broker_not_connected"
|
||||||
|
|
||||||
|
if broker_name == "ZERODHA":
|
||||||
|
try:
|
||||||
|
session = get_zerodha_session(user_id)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[STRATEGY] failed to load Zerodha session for {user_id}: {exc}", flush=True)
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
|
api_key = str(session.get("api_key") or "").strip()
|
||||||
|
access_token = str(session.get("access_token") or "").strip()
|
||||||
|
if not api_key or not access_token:
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetch_zerodha_funds(api_key, access_token)
|
||||||
|
except KiteTokenError:
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False, broker_state, "broker_auth_required"
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[STRATEGY] failed to validate Zerodha session for {user_id}: {exc}", flush=True)
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
|
set_broker_auth_state(user_id, "VALID")
|
||||||
|
return True, broker_state, "ok"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = get_session(user_id)
|
session = get_groww_session(user_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[STRATEGY] failed to load Zerodha session for {user_id}: {exc}", flush=True)
|
print(f"[STRATEGY] failed to load Groww session for {user_id}: {exc}", flush=True)
|
||||||
set_broker_auth_state(user_id, "EXPIRED")
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
return False, broker_state, "broker_auth_required"
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
@ -341,19 +374,22 @@ def _validate_live_broker_session(user_id: str):
|
|||||||
set_broker_auth_state(user_id, "EXPIRED")
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
return False, broker_state, "broker_auth_required"
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
api_key = str(session.get("api_key") or "").strip()
|
|
||||||
access_token = str(session.get("access_token") or "").strip()
|
access_token = str(session.get("access_token") or "").strip()
|
||||||
if not api_key or not access_token:
|
if not access_token:
|
||||||
set_broker_auth_state(user_id, "EXPIRED")
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
return False, broker_state, "broker_auth_required"
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fetch_funds(api_key, access_token)
|
fetch_groww_funds(access_token)
|
||||||
except KiteTokenError:
|
except GrowwTokenError:
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False, broker_state, "broker_auth_required"
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
print(f"[STRATEGY] failed to validate Groww session for {user_id}: {exc}", flush=True)
|
||||||
set_broker_auth_state(user_id, "EXPIRED")
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
return False, broker_state, "broker_auth_required"
|
return False, broker_state, "broker_auth_required"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[STRATEGY] failed to validate Zerodha session for {user_id}: {exc}", flush=True)
|
print(f"[STRATEGY] failed to validate Groww session for {user_id}: {exc}", flush=True)
|
||||||
set_broker_auth_state(user_id, "EXPIRED")
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
return False, broker_state, "broker_auth_required"
|
return False, broker_state, "broker_auth_required"
|
||||||
|
|
||||||
|
|||||||
@ -7,10 +7,12 @@ from psycopg2.extras import Json
|
|||||||
|
|
||||||
from app.broker_store import get_user_broker, set_broker_auth_state
|
from app.broker_store import get_user_broker, set_broker_auth_state
|
||||||
from app.services.db import db_connection
|
from app.services.db import db_connection
|
||||||
|
from app.services.groww_service import GrowwApiError, GrowwTokenError, fetch_funds as fetch_groww_funds
|
||||||
|
from app.services.groww_storage import get_session as get_groww_session
|
||||||
from app.services.run_lifecycle import RunLifecycleError, RunLifecycleManager
|
from app.services.run_lifecycle import RunLifecycleError, RunLifecycleManager
|
||||||
from app.services.strategy_service import compute_next_eligible, resume_running_runs
|
from app.services.strategy_service import compute_next_eligible, resume_running_runs
|
||||||
from app.services.zerodha_service import KiteTokenError, fetch_funds
|
from app.services.zerodha_service import KiteTokenError, fetch_funds as fetch_zerodha_funds
|
||||||
from app.services.zerodha_storage import get_session
|
from app.services.zerodha_storage import get_session as get_zerodha_session
|
||||||
|
|
||||||
|
|
||||||
def _hash_value(value: str | None) -> str | None:
|
def _hash_value(value: str | None) -> str | None:
|
||||||
@ -66,14 +68,29 @@ def _parse_ts(value: str | None):
|
|||||||
|
|
||||||
|
|
||||||
def _validate_broker_session(user_id: str):
|
def _validate_broker_session(user_id: str):
|
||||||
session = get_session(user_id)
|
broker_state = get_user_broker(user_id) or {}
|
||||||
if not session:
|
broker_name = (broker_state.get("broker") or "").strip().upper()
|
||||||
|
if broker_name not in {"ZERODHA", "GROWW"}:
|
||||||
return False
|
return False
|
||||||
if os.getenv("BROKER_VALIDATION_MODE", "").strip().lower() == "skip":
|
if os.getenv("BROKER_VALIDATION_MODE", "").strip().lower() == "skip":
|
||||||
return True
|
return True
|
||||||
|
if broker_name == "ZERODHA":
|
||||||
|
session = get_zerodha_session(user_id)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fetch_zerodha_funds(session["api_key"], session["access_token"])
|
||||||
|
except KiteTokenError:
|
||||||
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
session = get_groww_session(user_id)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
fetch_funds(session["api_key"], session["access_token"])
|
fetch_groww_funds(session["access_token"])
|
||||||
except KiteTokenError:
|
except (GrowwTokenError, GrowwApiError):
|
||||||
set_broker_auth_state(user_id, "EXPIRED")
|
set_broker_auth_state(user_id, "EXPIRED")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -462,6 +462,394 @@ class LiveZerodhaBroker(Broker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LiveGrowwBroker(Broker):
|
||||||
|
external_orders = True
|
||||||
|
|
||||||
|
FILLED_STATUSES = {"EXECUTED", "DELIVERY_AWAITED", "COMPLETED"}
|
||||||
|
REJECTED_STATUSES = {"REJECTED", "FAILED"}
|
||||||
|
CANCELLED_STATUSES = {"CANCELLED", "CANCELLATION_REQUESTED"}
|
||||||
|
TERMINAL_STATUSES = FILLED_STATUSES | REJECTED_STATUSES | CANCELLED_STATUSES
|
||||||
|
POLL_TIMEOUT_SECONDS = float(os.getenv("GROWW_ORDER_POLL_TIMEOUT", "15"))
|
||||||
|
POLL_INTERVAL_SECONDS = float(os.getenv("GROWW_ORDER_POLL_INTERVAL", "1"))
|
||||||
|
|
||||||
|
def __init__(self, user_id: str | None = None, run_id: str | None = None):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.run_id = run_id
|
||||||
|
|
||||||
|
def _scope(self):
|
||||||
|
return _resolve_scope(self.user_id, self.run_id)
|
||||||
|
|
||||||
|
def _session(self):
|
||||||
|
from app.services.groww_storage import get_session
|
||||||
|
|
||||||
|
user_id, _run_id = self._scope()
|
||||||
|
session = get_session(user_id)
|
||||||
|
if not session or not session.get("access_token"):
|
||||||
|
raise BrokerAuthExpired("Groww session missing. Please reconnect broker.")
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _raise_auth_expired(self, exc: Exception):
|
||||||
|
from app.broker_store import expire_user_broker_session
|
||||||
|
|
||||||
|
user_id, _run_id = self._scope()
|
||||||
|
expire_user_broker_session(user_id)
|
||||||
|
raise BrokerAuthExpired(str(exc)) from exc
|
||||||
|
|
||||||
|
def _normalize_symbol(self, symbol: str) -> tuple[str, str, str]:
|
||||||
|
cleaned = (symbol or "").strip().upper()
|
||||||
|
if cleaned.endswith(".NS"):
|
||||||
|
return cleaned[:-3], "NSE", "CASH"
|
||||||
|
if cleaned.endswith(".BO"):
|
||||||
|
return cleaned[:-3], "BSE", "CASH"
|
||||||
|
return cleaned, "NSE", "CASH"
|
||||||
|
|
||||||
|
def _make_reference_id(self, logical_time: datetime | None, symbol: str, side: str) -> str:
|
||||||
|
user_id, run_id = self._scope()
|
||||||
|
logical_ts = logical_time or datetime.utcnow().replace(tzinfo=timezone.utc)
|
||||||
|
digest = hashlib.sha1(
|
||||||
|
f"{user_id}|{run_id}|{_normalize_ts_for_id(logical_ts)}|{symbol}|{side}".encode("utf-8")
|
||||||
|
).hexdigest()[:18]
|
||||||
|
return f"qfg{digest}"
|
||||||
|
|
||||||
|
def _first_text(self, *values, default: str = "") -> str:
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _first_float(self, *values, default: float = 0.0) -> float:
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
def _extract_order_id(self, payload: dict | None) -> str:
|
||||||
|
entry = payload or {}
|
||||||
|
return self._first_text(
|
||||||
|
entry.get("groww_order_id"),
|
||||||
|
entry.get("order_id"),
|
||||||
|
entry.get("id"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_order_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
requested_qty: int,
|
||||||
|
requested_price: float | None,
|
||||||
|
order_entry: dict | None,
|
||||||
|
logical_time: datetime | None,
|
||||||
|
) -> dict:
|
||||||
|
entry = order_entry or {}
|
||||||
|
raw_status = self._first_text(
|
||||||
|
entry.get("order_status"),
|
||||||
|
entry.get("status"),
|
||||||
|
entry.get("state"),
|
||||||
|
default="",
|
||||||
|
).upper()
|
||||||
|
if raw_status in self.FILLED_STATUSES:
|
||||||
|
status = "FILLED"
|
||||||
|
elif raw_status in self.REJECTED_STATUSES:
|
||||||
|
status = "REJECTED"
|
||||||
|
elif raw_status in self.CANCELLED_STATUSES:
|
||||||
|
status = "CANCELLED"
|
||||||
|
else:
|
||||||
|
status = "PENDING"
|
||||||
|
|
||||||
|
quantity = int(self._first_float(entry.get("quantity"), requested_qty, default=0))
|
||||||
|
filled_qty = int(
|
||||||
|
self._first_float(
|
||||||
|
entry.get("filled_quantity"),
|
||||||
|
entry.get("executed_quantity"),
|
||||||
|
entry.get("filled_qty"),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
average_price = self._first_float(
|
||||||
|
entry.get("average_price"),
|
||||||
|
entry.get("avg_price"),
|
||||||
|
entry.get("average_execution_price"),
|
||||||
|
requested_price,
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
price = self._first_float(entry.get("price"), requested_price, average_price, default=0.0)
|
||||||
|
timestamp = self._first_text(
|
||||||
|
entry.get("order_timestamp"),
|
||||||
|
entry.get("timestamp"),
|
||||||
|
entry.get("updated_at"),
|
||||||
|
entry.get("created_at"),
|
||||||
|
default=_format_utc_ts(logical_time or datetime.utcnow().replace(tzinfo=timezone.utc)) or "",
|
||||||
|
)
|
||||||
|
if timestamp and " " in timestamp:
|
||||||
|
timestamp = timestamp.replace(" ", "T")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": order_id,
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side.upper().strip(),
|
||||||
|
"qty": quantity,
|
||||||
|
"requested_qty": quantity,
|
||||||
|
"filled_qty": filled_qty,
|
||||||
|
"price": price,
|
||||||
|
"requested_price": float(requested_price or price or 0.0),
|
||||||
|
"average_price": average_price,
|
||||||
|
"status": status,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"broker_order_id": order_id,
|
||||||
|
"exchange": self._first_text(entry.get("exchange"), default=None),
|
||||||
|
"tradingsymbol": self._first_text(
|
||||||
|
entry.get("trading_symbol"),
|
||||||
|
entry.get("tradingsymbol"),
|
||||||
|
entry.get("symbol"),
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
"status_message": self._first_text(
|
||||||
|
entry.get("remark"),
|
||||||
|
entry.get("status_message"),
|
||||||
|
entry.get("message"),
|
||||||
|
entry.get("error_message"),
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _wait_for_terminal_order(
|
||||||
|
self,
|
||||||
|
session: dict,
|
||||||
|
order_id: str,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
requested_qty: int,
|
||||||
|
requested_price: float | None,
|
||||||
|
logical_time: datetime | None,
|
||||||
|
segment: str,
|
||||||
|
) -> dict:
|
||||||
|
from app.services.groww_service import (
|
||||||
|
GrowwApiError,
|
||||||
|
GrowwTokenError,
|
||||||
|
fetch_order_detail,
|
||||||
|
fetch_order_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
last_payload = self._normalize_order_payload(
|
||||||
|
order_id=order_id,
|
||||||
|
symbol=symbol,
|
||||||
|
side=side,
|
||||||
|
requested_qty=requested_qty,
|
||||||
|
requested_price=requested_price,
|
||||||
|
order_entry=None,
|
||||||
|
logical_time=logical_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
detail = fetch_order_detail(session["access_token"], order_id, segment=segment)
|
||||||
|
status_payload = fetch_order_status(session["access_token"], order_id, segment=segment)
|
||||||
|
merged = {}
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
merged.update(detail)
|
||||||
|
if isinstance(status_payload, dict):
|
||||||
|
merged.update(status_payload)
|
||||||
|
except GrowwTokenError as exc:
|
||||||
|
self._raise_auth_expired(exc)
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
merged = {
|
||||||
|
"groww_order_id": order_id,
|
||||||
|
"order_status": "FAILED",
|
||||||
|
"remark": getattr(exc, "message", str(exc)),
|
||||||
|
}
|
||||||
|
|
||||||
|
last_payload = self._normalize_order_payload(
|
||||||
|
order_id=order_id,
|
||||||
|
symbol=symbol,
|
||||||
|
side=side,
|
||||||
|
requested_qty=requested_qty,
|
||||||
|
requested_price=requested_price,
|
||||||
|
order_entry=merged,
|
||||||
|
logical_time=logical_time,
|
||||||
|
)
|
||||||
|
raw_status = self._first_text(
|
||||||
|
merged.get("order_status"),
|
||||||
|
merged.get("status"),
|
||||||
|
merged.get("state"),
|
||||||
|
default="",
|
||||||
|
).upper()
|
||||||
|
if raw_status in self.TERMINAL_STATUSES:
|
||||||
|
return last_payload
|
||||||
|
|
||||||
|
if time.monotonic() - started >= self.POLL_TIMEOUT_SECONDS:
|
||||||
|
return last_payload
|
||||||
|
|
||||||
|
time.sleep(self.POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
def get_funds(self, cur=None):
|
||||||
|
from app.services.groww_service import GrowwTokenError, fetch_funds
|
||||||
|
|
||||||
|
session = self._session()
|
||||||
|
try:
|
||||||
|
data = fetch_funds(session["access_token"])
|
||||||
|
except GrowwTokenError as exc:
|
||||||
|
self._raise_auth_expired(exc)
|
||||||
|
|
||||||
|
available = data.get("available") if isinstance(data.get("available"), dict) else {}
|
||||||
|
equity = data.get("equity") if isinstance(data.get("equity"), dict) else {}
|
||||||
|
equity_available = equity.get("available") if isinstance(equity.get("available"), dict) else {}
|
||||||
|
cash = self._first_float(
|
||||||
|
data.get("cash"),
|
||||||
|
data.get("available_cash"),
|
||||||
|
data.get("available_balance"),
|
||||||
|
available.get("cash"),
|
||||||
|
available.get("available_cash"),
|
||||||
|
available.get("balance"),
|
||||||
|
equity.get("cash"),
|
||||||
|
equity_available.get("cash"),
|
||||||
|
equity_available.get("live_balance"),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
return {"cash": float(cash), "raw": data}
|
||||||
|
|
||||||
|
def get_positions(self):
|
||||||
|
from app.services.groww_service import GrowwTokenError, fetch_holdings, normalize_holding
|
||||||
|
|
||||||
|
session = self._session()
|
||||||
|
try:
|
||||||
|
holdings = fetch_holdings(session["access_token"])
|
||||||
|
except GrowwTokenError as exc:
|
||||||
|
self._raise_auth_expired(exc)
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for item in holdings:
|
||||||
|
entry = normalize_holding(item)
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"symbol": entry.get("symbol"),
|
||||||
|
"qty": float(entry.get("effective_quantity") or 0.0),
|
||||||
|
"avg_price": float(entry.get("average_price") or 0.0),
|
||||||
|
"last_price": float(entry.get("last_price") or 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def get_orders(self):
|
||||||
|
from app.services.groww_service import GrowwTokenError, fetch_orders
|
||||||
|
|
||||||
|
session = self._session()
|
||||||
|
try:
|
||||||
|
return fetch_orders(session["access_token"])
|
||||||
|
except GrowwTokenError as exc:
|
||||||
|
self._raise_auth_expired(exc)
|
||||||
|
|
||||||
|
def place_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
quantity: float,
|
||||||
|
price: float | None = None,
|
||||||
|
cur=None,
|
||||||
|
logical_time: datetime | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
):
|
||||||
|
from app.services.groww_service import GrowwApiError, GrowwTokenError, place_order
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
self.user_id = user_id
|
||||||
|
if run_id is not None:
|
||||||
|
self.run_id = run_id
|
||||||
|
|
||||||
|
qty = int(math.floor(float(quantity)))
|
||||||
|
side = side.upper().strip()
|
||||||
|
requested_price = float(price) if price is not None else None
|
||||||
|
if qty <= 0:
|
||||||
|
return {
|
||||||
|
"id": _deterministic_id("groww_rej", [symbol, side, _stable_num(quantity)]),
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side,
|
||||||
|
"qty": qty,
|
||||||
|
"requested_qty": qty,
|
||||||
|
"filled_qty": 0,
|
||||||
|
"price": float(price or 0.0),
|
||||||
|
"requested_price": float(price or 0.0),
|
||||||
|
"average_price": 0.0,
|
||||||
|
"status": "REJECTED",
|
||||||
|
"timestamp": _format_utc_ts(logical_time or datetime.utcnow().replace(tzinfo=timezone.utc)),
|
||||||
|
"status_message": "Computed quantity is less than 1 share",
|
||||||
|
}
|
||||||
|
|
||||||
|
session = self._session()
|
||||||
|
trading_symbol, exchange, segment = self._normalize_symbol(symbol)
|
||||||
|
order_reference_id = self._make_reference_id(logical_time, symbol, side)
|
||||||
|
rejected_timestamp = _format_utc_ts(logical_time or datetime.utcnow().replace(tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
placed = place_order(
|
||||||
|
session["access_token"],
|
||||||
|
trading_symbol=trading_symbol,
|
||||||
|
exchange=exchange,
|
||||||
|
segment=segment,
|
||||||
|
transaction_type=side,
|
||||||
|
order_type="MARKET",
|
||||||
|
quantity=qty,
|
||||||
|
product="CNC",
|
||||||
|
validity="DAY",
|
||||||
|
price=requested_price,
|
||||||
|
order_reference_id=order_reference_id,
|
||||||
|
)
|
||||||
|
except GrowwTokenError as exc:
|
||||||
|
self._raise_auth_expired(exc)
|
||||||
|
except GrowwApiError as exc:
|
||||||
|
return {
|
||||||
|
"id": _deterministic_id(
|
||||||
|
"groww_rej",
|
||||||
|
[
|
||||||
|
symbol,
|
||||||
|
side,
|
||||||
|
_stable_num(quantity),
|
||||||
|
_stable_num(requested_price or 0.0),
|
||||||
|
getattr(exc, "error_type", "groww_error"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side,
|
||||||
|
"qty": qty,
|
||||||
|
"requested_qty": qty,
|
||||||
|
"filled_qty": 0,
|
||||||
|
"price": float(requested_price or 0.0),
|
||||||
|
"requested_price": float(requested_price or 0.0),
|
||||||
|
"average_price": 0.0,
|
||||||
|
"status": "REJECTED",
|
||||||
|
"timestamp": rejected_timestamp,
|
||||||
|
"status_message": getattr(exc, "message", str(exc)),
|
||||||
|
"error_type": getattr(exc, "error_type", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
order_id = self._extract_order_id(placed)
|
||||||
|
if not order_id:
|
||||||
|
raise BrokerError("Groww order placement did not return an order id")
|
||||||
|
|
||||||
|
return self._wait_for_terminal_order(
|
||||||
|
session,
|
||||||
|
order_id,
|
||||||
|
symbol=symbol,
|
||||||
|
side=side,
|
||||||
|
requested_qty=qty,
|
||||||
|
requested_price=requested_price,
|
||||||
|
logical_time=logical_time,
|
||||||
|
segment=segment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaperBroker(Broker):
|
class PaperBroker(Broker):
|
||||||
initial_cash: float
|
initial_cash: float
|
||||||
|
|||||||
@ -7,7 +7,12 @@ from psycopg2.extras import Json
|
|||||||
|
|
||||||
from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open, market_now
|
from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open, market_now
|
||||||
from indian_paper_trading_strategy.engine.execution import try_execute_sip
|
from indian_paper_trading_strategy.engine.execution import try_execute_sip
|
||||||
from indian_paper_trading_strategy.engine.broker import PaperBroker, LiveZerodhaBroker, BrokerAuthExpired
|
from indian_paper_trading_strategy.engine.broker import (
|
||||||
|
BrokerAuthExpired,
|
||||||
|
LiveGrowwBroker,
|
||||||
|
LiveZerodhaBroker,
|
||||||
|
PaperBroker,
|
||||||
|
)
|
||||||
from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm
|
from indian_paper_trading_strategy.engine.mtm import log_mtm, should_log_mtm
|
||||||
from indian_paper_trading_strategy.engine.state import load_state
|
from indian_paper_trading_strategy.engine.state import load_state
|
||||||
from indian_paper_trading_strategy.engine.data import fetch_live_price
|
from indian_paper_trading_strategy.engine.data import fetch_live_price
|
||||||
@ -266,6 +271,8 @@ def _engine_loop(config, stop_event: threading.Event):
|
|||||||
)
|
)
|
||||||
elif broker_type == "zerodha":
|
elif broker_type == "zerodha":
|
||||||
broker = LiveZerodhaBroker(user_id=scope_user, run_id=scope_run)
|
broker = LiveZerodhaBroker(user_id=scope_user, run_id=scope_run)
|
||||||
|
elif broker_type == "groww":
|
||||||
|
broker = LiveGrowwBroker(user_id=scope_user, run_id=scope_run)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported broker: {broker_type}")
|
raise ValueError(f"Unsupported broker: {broker_type}")
|
||||||
market_data_provider = "yfinance"
|
market_data_provider = "yfinance"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user