2026-02-01 13:57:30 +00:00

235 lines
7.4 KiB
Python

from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.broker_store import clear_user_broker
from app.services.auth_service import get_user_for_session
from app.services.zerodha_service import (
KiteApiError,
KiteTokenError,
build_login_url,
exchange_request_token,
fetch_funds,
fetch_holdings,
)
from app.services.zerodha_storage import (
clear_session,
consume_request_token,
get_session,
set_session,
store_request_token,
)
router = APIRouter(prefix="/api/zerodha")
public_router = APIRouter()
def _require_user(request: Request):
session_id = request.cookies.get("session_id")
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = get_user_for_session(session_id)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def _capture_request_token(request: Request, request_token: str):
user = _require_user(request)
token = request_token.strip()
if not token:
raise HTTPException(status_code=400, detail="Missing request_token")
store_request_token(user["id"], token)
def _clear_broker_session(user_id: str):
clear_user_broker(user_id)
clear_session(user_id)
def _raise_kite_error(user_id: str, exc: KiteApiError):
if isinstance(exc, KiteTokenError):
_clear_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
@router.post("/login-url")
async def login_url(payload: dict, request: Request):
_require_user(request)
api_key = (payload.get("apiKey") or "").strip()
if not api_key:
raise HTTPException(status_code=400, detail="API key is required")
return {"loginUrl": build_login_url(api_key)}
@router.post("/session")
async def create_session(payload: dict, request: Request):
user = _require_user(request)
api_key = (payload.get("apiKey") or "").strip()
api_secret = (payload.get("apiSecret") or "").strip()
request_token = (payload.get("requestToken") or "").strip()
if not api_key or not api_secret or not request_token:
raise HTTPException(
status_code=400, detail="API key, secret, and request token are required"
)
try:
session_data = exchange_request_token(api_key, api_secret, request_token)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
saved = set_session(
user["id"],
{
"api_key": api_key,
"access_token": session_data.get("access_token"),
"request_token": session_data.get("request_token", request_token),
"user_name": session_data.get("user_name"),
"broker_user_id": session_data.get("user_id"),
},
)
return {
"connected": True,
"userName": saved.get("user_name"),
"brokerUserId": saved.get("broker_user_id"),
"accessToken": saved.get("access_token"),
}
@router.get("/status")
async def status(request: Request):
user = _require_user(request)
session = get_session(user["id"])
if not session:
return {"connected": False}
return {
"connected": True,
"broker": "zerodha",
"userName": session.get("user_name"),
"linkedAt": session.get("linked_at"),
}
@router.get("/request-token")
async def request_token(request: Request):
user = _require_user(request)
token = consume_request_token(user["id"])
if not token:
raise HTTPException(status_code=404, detail="No request token available.")
return {"requestToken": token}
@router.get("/holdings")
async def holdings(request: Request):
user = _require_user(request)
session = get_session(user["id"])
if not session:
raise HTTPException(status_code=400, detail="Zerodha is not connected")
try:
data = fetch_holdings(session["api_key"], session["access_token"])
except KiteApiError as exc:
_raise_kite_error(user["id"], exc)
return {"holdings": data}
@router.get("/funds")
async def funds(request: Request):
user = _require_user(request)
session = get_session(user["id"])
if not session:
raise HTTPException(status_code=400, detail="Zerodha is not connected")
try:
data = fetch_funds(session["api_key"], session["access_token"])
except KiteApiError as exc:
_raise_kite_error(user["id"], exc)
equity = data.get("equity", {}) if isinstance(data, dict) else {}
return {"funds": {**equity, "raw": data}}
@router.get("/equity-curve")
async def equity_curve(request: Request, from_: str = Query("", alias="from")):
user = _require_user(request)
session = get_session(user["id"])
if not session:
raise HTTPException(status_code=400, detail="Zerodha is not connected")
try:
holdings = fetch_holdings(session["api_key"], session["access_token"])
funds_data = fetch_funds(session["api_key"], session["access_token"])
except KiteApiError as exc:
_raise_kite_error(user["id"], exc)
equity = funds_data.get("equity", {}) if isinstance(funds_data, dict) else {}
total_holdings_value = 0
for item in holdings:
qty = float(item.get("quantity") or item.get("qty") or 0)
last = float(item.get("last_price") or item.get("average_price") or 0)
total_holdings_value += qty * last
total_funds = float(equity.get("cash") or 0)
current_value = max(0, total_holdings_value + total_funds)
ms_in_day = 86400000
now = datetime.utcnow()
default_start = now - timedelta(days=90)
if from_:
try:
start_date = datetime.fromisoformat(from_)
except ValueError:
start_date = default_start
else:
start_date = default_start
if start_date > now:
start_date = now
span_days = max(
2,
int(((now - start_date).total_seconds() * 1000) // ms_in_day),
)
start_value = current_value * 0.85 if current_value > 0 else 10000
points = []
for i in range(span_days):
day = start_date + timedelta(days=i)
progress = i / (span_days - 1)
trend = start_value + (current_value - start_value) * progress
value = max(0, round(trend))
points.append({"date": day.isoformat(), "value": value})
return {
"startDate": start_date.isoformat(),
"endDate": now.isoformat(),
"accountOpenDate": session.get("linked_at"),
"points": points,
}
@router.get("/callback")
async def callback(request: Request, request_token: str = ""):
_capture_request_token(request, request_token)
return {
"status": "ok",
"message": "Request token captured. You can close this tab.",
}
@router.get("/login")
async def login_redirect(request: Request, request_token: str = ""):
return await callback(request, request_token=request_token)
@public_router.get("/login", response_class=HTMLResponse)
async def login_capture(request: Request, request_token: str = ""):
_capture_request_token(request, request_token)
return (
"<html><body style=\"font-family:sans-serif;padding:24px;\">"
"<h3>Request token captured</h3>"
"<p>You can close this tab and return to QuantFortune.</p>"
"</body></html>"
)