From 565de6445970d21b94cf232da1e6761541017d37 Mon Sep 17 00:00:00 2001 From: Thigazhezhilan J Date: Mon, 6 Apr 2026 11:31:29 +0530 Subject: [PATCH] Add live broker positions to portfolio API --- backend/app/routers/broker.py | 100 ++++++++++++++++++------ backend/app/services/groww_service.py | 6 ++ backend/app/services/zerodha_service.py | 62 +++++++++++++++ 3 files changed, 142 insertions(+), 26 deletions(-) diff --git a/backend/app/routers/broker.py b/backend/app/routers/broker.py index ecc98d7..a077b38 100644 --- a/backend/app/routers/broker.py +++ b/backend/app/routers/broker.py @@ -22,9 +22,11 @@ from app.services.groww_service import ( fetch_funds as fetch_groww_funds, fetch_holdings as fetch_groww_holdings, fetch_ltp as fetch_groww_ltp, + fetch_positions as fetch_groww_positions, fetch_profile as fetch_groww_profile, generate_access_token, normalize_holding as normalize_groww_holding, + normalize_position as normalize_groww_position, ) 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 @@ -35,7 +37,9 @@ from app.services.zerodha_service import ( exchange_request_token, fetch_funds as fetch_zerodha_funds, fetch_holdings as fetch_zerodha_holdings, + fetch_positions as fetch_zerodha_positions, normalize_holding as normalize_zerodha_holding, + normalize_position as normalize_zerodha_position, ) from app.services.zerodha_storage import ( clear_session as clear_zerodha_session, @@ -181,36 +185,53 @@ 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 + normalized = _normalize_enrich_groww_item(access_token, item, normalize_groww_holding) holdings.append(normalized) return holdings +def _normalize_enrich_groww_item(access_token: str, item: dict, normalizer) -> dict: + normalized = normalizer(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"] = _first_number( + normalized.get("display_pnl"), + normalized.get("effective_quantity", 0) + * (normalized["last_price"] - normalized.get("average_price", 0)), + default=0.0, + ) + except GrowwApiError: + pass + return normalized + + +def _fetch_normalized_groww_positions(access_token: str) -> list[dict]: + items = fetch_groww_positions(access_token) + positions: list[dict] = [] + for item in items: + normalized = _normalize_enrich_groww_item(access_token, item, normalize_groww_position) + positions.append(normalized) + return positions + + 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 {} @@ -603,6 +624,33 @@ async def broker_holdings(request: Request): raise HTTPException(status_code=400, detail=f"Unsupported broker: {broker_name}") +@router.get("/positions") +async def broker_positions(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_positions(session["api_key"], session["access_token"]) + except KiteApiError as exc: + _raise_zerodha_error(user["id"], exc) + return {"broker": broker_name, "positions": [normalize_zerodha_position(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: + positions = _fetch_normalized_groww_positions(session["access_token"]) + except GrowwApiError as exc: + _raise_groww_error(user["id"], exc) + return {"broker": broker_name, "positions": positions} + + raise HTTPException(status_code=400, detail=f"Unsupported broker: {broker_name}") + + @router.get("/funds") async def broker_funds(request: Request): user = _require_user(request) diff --git a/backend/app/services/groww_service.py b/backend/app/services/groww_service.py index ddc0c2f..d824951 100644 --- a/backend/app/services/groww_service.py +++ b/backend/app/services/groww_service.py @@ -358,3 +358,9 @@ def normalize_holding(item: dict | None) -> dict: entry["display_pnl"] = quantity * (last_price - average_price) entry["holding_value"] = quantity * last_price return entry + + +def normalize_position(item: dict | None) -> dict: + entry = normalize_holding(item) + entry["product"] = _first_text(entry.get("product"), entry.get("product_type"), default="CNC").upper() + return entry diff --git a/backend/app/services/zerodha_service.py b/backend/app/services/zerodha_service.py index 61fe717..53992b6 100644 --- a/backend/app/services/zerodha_service.py +++ b/backend/app/services/zerodha_service.py @@ -157,6 +157,19 @@ def fetch_holdings(api_key: str, access_token: str) -> list: return response.get("data", []) +def fetch_positions(api_key: str, access_token: str) -> list: + url = f"{KITE_API_BASE}/portfolio/positions" + response = _request("GET", url, headers=_auth_headers(api_key, access_token)) + data = response.get("data", {}) + if isinstance(data, dict): + net_positions = data.get("net") + if isinstance(net_positions, list): + return net_positions + if isinstance(data, list): + return data + return [] + + def fetch_funds(api_key: str, access_token: str) -> dict: url = f"{KITE_API_BASE}/user/margins" response = _request("GET", url, headers=_auth_headers(api_key, access_token)) @@ -260,6 +273,55 @@ def fetch_order_history(api_key: str, access_token: str, order_id: str) -> list: return response.get("data", []) +def position_quantity(item: dict | None) -> float: + entry = item or {} + return _first_float( + entry.get("net_quantity"), + entry.get("quantity"), + entry.get("qty"), + default=0.0, + ) + + +def position_average_price(item: dict | None) -> float: + entry = item or {} + return _first_float( + entry.get("average_price"), + entry.get("avg_price"), + entry.get("buy_price"), + default=0.0, + ) + + +def position_last_price(item: dict | None) -> float: + entry = item or {} + return _first_float( + entry.get("last_price"), + entry.get("close_price"), + entry.get("average_price"), + entry.get("avg_price"), + default=0.0, + ) + + +def normalize_position(item: dict | None) -> dict: + entry = dict(item or {}) + quantity = position_quantity(entry) + average_price = position_average_price(entry) + last_price = position_last_price(entry) + pnl = _first_float(entry.get("pnl"), default=quantity * (last_price - average_price)) + 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["display_pnl"] = pnl + entry["holding_value"] = quantity * last_price + return entry + + def cancel_order( api_key: str, access_token: str,