| import json |
| import asyncio |
| import hmac |
| import hashlib |
| import datetime |
| import smtplib |
| import random |
| from email.mime.text import MIMEText |
| from email.mime.multipart import MIMEMultipart |
| from typing import Optional |
| import jwt |
| import httpx |
| from fastapi import APIRouter, Depends, HTTPException, Request, Header |
| from sqlalchemy.ext.asyncio import AsyncSession |
|
|
| from backend.app.config.settings import settings |
| from backend.app.database.session import get_db |
| from backend.app.database import crud |
| from backend.app.schemas import schemas |
| from backend.app.config.metrics import payment_callbacks_total, external_api_calls_total |
| from backend.app.services.gemini import get_onnx_session_for_type |
|
|
| router= APIRouter() |
|
|
| |
| def create_access_token(data: dict, expires_delta: Optional[datetime.timedelta]= None) -> str: |
| """ |
| Generates a secure JSON Web Token (JWT) signed with the application SECRET_KEY. |
| """ |
| to_encode= data.copy() |
| if expires_delta: |
| expire= datetime.datetime.now(datetime.timezone.utc) + expires_delta |
| else: |
| expire= datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes= settings.ACCESS_TOKEN_EXPIRE_MINUTES) |
| to_encode.update({"exp": expire}) |
| encode_jwt= jwt.encode(to_encode, settings.SECRET_KEY, algorithm= "HS256") |
| return encode_jwt |
|
|
| def decode_access_token(token: str) -> Optional[dict]: |
| """ |
| Decodes and validates a JWT token. Returns the payload dictionary or None if invalid. |
| """ |
| try: |
| payload= jwt.decode(token, settings.SECRET_KEY, algorithms= ["HS256"]) |
| return payload |
| except jwt.PyJWTError: |
| return None |
|
|
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials |
| from fastapi import File, UploadFile |
| from backend.app.database import models |
| from backend.app.services.cloudinary_service import upload_avatar |
| import uuid |
|
|
| security= HTTPBearer() |
|
|
| async def get_current_user(credentials: HTTPAuthorizationCredentials= Depends(security), db: AsyncSession= Depends(get_db)) -> models.User: |
| token= credentials.credentials |
| payload= decode_access_token(token) |
| if not payload or "sub" not in payload: |
| raise HTTPException(status_code= 401, detail= "Invalid token or expired session.") |
| try: |
| user_id= uuid.UUID(payload["sub"]) |
| except ValueError: |
| raise HTTPException(status_code= 401, detail= "Invalid user ID in token.") |
| |
| user= await crud.get_user(db, user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail= "User not found.") |
| return user |
|
|
| |
| @router.post("/auth/google", response_model= schemas.Token) |
| async def google_auth(payload: schemas.GoogleAuthRequest, db: AsyncSession= Depends(get_db)): |
| """ |
| Authenticates a user using their Google OAuth ID Token. |
| Returns a custom JWT access token for subsequent API and GraphQL requests. |
| """ |
| token_id= payload.token_id |
| |
| |
| if token_id== "mock_token": |
| email= "tester@quantiq.io" |
| user= await crud.get_user_by_email(db, email) |
| if not user: |
| user_in= schemas.UserBase( |
| email= email, |
| full_name= "Local Tester", |
| picture_url= "https://via.placeholder.com/150" |
| ) |
| user= await crud.create_user(db, user_in, google_id= "mock_google_id_123") |
| token= create_access_token(data={"sub": str(user.id)}) |
| return {"access_token": token, "token_type": "bearer"} |
| |
| |
| async with httpx.AsyncClient() as client: |
| try: |
| response= await client.get(f"https://oauth2.googleapis.com/tokeninfo?id_token={token_id}", timeout= 10.0) |
| if response.status_code != 200: |
| raise HTTPException(status_code= 400, detail= "Invalid Google OAuth token") |
| |
| idinfo= response.json() |
| |
| |
| if settings.GOOGLE_CLIENT_ID and idinfo.get("aud") != settings.GOOGLE_CLIENT_ID: |
| raise HTTPException(status_code= 400, detail= "OAuth audience mismatch") |
| |
| email= idinfo.get("email") |
| name= idinfo.get("name") |
| picture= idinfo.get("picture") |
| google_id= idinfo.get("sub") |
| |
| if not email or not google_id: |
| raise HTTPException(status_code= 400, detail= "Incomplete Google user profile") |
| |
| except httpx.RequestError as e: |
| raise HTTPException(status_code= 503, detail= f"Google authentication unreachable: {str(e)}") |
| |
| |
| user= await crud.get_user_by_google_id(db, google_id) |
| if not user: |
| |
| user= await crud.get_user_by_email(db, email) |
| if not user: |
| user_in= schemas.UserBase(email= email, full_name= name, picture_url= picture) |
| user= await crud.create_user(db, user_in, google_id= google_id) |
| |
| |
| access_token= create_access_token(data= {"sub": str(user.id)}) |
| return {"access_token": access_token, "token_type": "bearer"} |
|
|
| |
| from pydantic import BaseModel |
|
|
| class EmailSignUpRequest(BaseModel): |
| email:str |
| full_name: str |
| country: str |
| password: str |
|
|
| class EmailLoginRequest(BaseModel): |
| email:str |
| password: str |
|
|
| @router.post("/auth/signup", response_model= schemas.AuthResponse) |
| async def email_signup(payload: EmailSignUpRequest, db: AsyncSession= Depends(get_db)): |
| """ |
| Registers a new user using their email, name, country, and password. |
| Saves authentication credentials inside the existing schema. |
| Generates a 6-digit OTP code, saves it to the database, sends the verification |
| email via Celery, and indicates verification is required. |
| """ |
| |
| existing_user= await crud.get_user_by_email(db, payload.email) |
| |
| |
| pwd_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest() |
| google_id = f"local:{pwd_hash}" |
| |
| if existing_user: |
| if existing_user.is_verified: |
| |
| if existing_user.google_id.startswith("local:"): |
| raise HTTPException(status_code= 400, detail= "Email is already registered.") |
| else: |
| |
| raise HTTPException(status_code= 400, detail= "This email is linked to a Google account. Please sign in with Google.") |
| else: |
| |
| existing_user.full_name = payload.full_name |
| existing_user.google_id = google_id |
| user = existing_user |
| else: |
| |
| user_in= schemas.UserBase( |
| email= payload.email, |
| full_name= payload.full_name, |
| picture_url= None |
| ) |
| try: |
| user= await crud.create_user(db, user_in, google_id= google_id) |
| except Exception as e: |
| raise HTTPException(status_code= 500, detail=f"Failed to register user: {str(e)}") |
|
|
| |
| code = f"{random.randint(100000, 999999)}" |
| expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15) |
| |
| user.is_verified = False |
| user.verification_code = code |
| user.verification_code_expires_at = expires_at |
| |
| db.add(user) |
| await db.commit() |
| await db.refresh(user) |
| |
| |
| try: |
| from backend.app.services.celery_app import send_verification_email_task |
| send_verification_email_task.delay(user.email, code) |
| except Exception as email_err: |
| print(f"Failed to trigger verification email Celery task: {email_err}") |
| |
| return { |
| "access_token": None, |
| "token_type": "bearer", |
| "verification_required": True, |
| "email": user.email |
| } |
|
|
| @router.post("/auth/login", response_model= schemas.AuthResponse) |
| async def email_login(payload: EmailLoginRequest, db: AsyncSession= Depends(get_db)): |
| """ |
| Authenticates a user via traditional email and password. |
| If credentials match but user is not verified, regenerates and sends a new OTP. |
| Returns custom JWT token or verification status. |
| """ |
| user= await crud.get_user_by_email(db, payload.email) |
| if not user: |
| raise HTTPException(status_code= 400, detail= "Invalid email or password.") |
| |
| |
| if not user.google_id or not user.google_id.startswith("local:"): |
| raise HTTPException(status_code= 400, detail= "Invalid login method. Please sign in using Google.") |
| |
| stored_hash= user.google_id[len("local:"):] |
| input_hash= hashlib.sha256(payload.password.encode("utf-8")).hexdigest() |
| |
| if stored_hash != input_hash: |
| raise HTTPException(status_code= 400, detail= "Invalid email or password.") |
| |
| |
| if not user.is_verified: |
| |
| code = f"{random.randint(100000, 999999)}" |
| expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15) |
| |
| user.verification_code = code |
| user.verification_code_expires_at = expires_at |
| |
| db.add(user) |
| await db.commit() |
| await db.refresh(user) |
| |
| try: |
| from backend.app.services.celery_app import send_verification_email_task |
| send_verification_email_task.delay(user.email, code) |
| except Exception as email_err: |
| print(f"Failed to trigger verification email Celery task: {email_err}") |
| |
| return { |
| "access_token": None, |
| "token_type": "bearer", |
| "verification_required": True, |
| "email": user.email |
| } |
| |
| token= create_access_token(data={"sub": str(user.id)}) |
| return { |
| "access_token": token, |
| "token_type": "bearer", |
| "verification_required": False, |
| "email": user.email |
| } |
|
|
| @router.post("/auth/verify", response_model= schemas.AuthResponse) |
| async def verify_email(payload: schemas.EmailVerifyRequest, db: AsyncSession= Depends(get_db)): |
| """ |
| Verifies a user's email address by checking the 6-digit OTP code. |
| If valid, activates the account and returns a signed access token. |
| """ |
| user = await crud.get_user_by_email(db, payload.email) |
| if not user: |
| raise HTTPException(status_code= 400, detail= "Invalid verification request.") |
| |
| if user.is_verified: |
| raise HTTPException(status_code= 400, detail= "Email is already verified.") |
| |
| if not user.verification_code or user.verification_code != payload.code: |
| raise HTTPException(status_code= 400, detail= "Invalid verification code.") |
| |
| now_utc = datetime.datetime.now(datetime.timezone.utc) |
| expires_at = user.verification_code_expires_at |
| if expires_at.tzinfo is None: |
| expires_at = expires_at.replace(tzinfo=datetime.timezone.utc) |
| |
| if now_utc > expires_at: |
| raise HTTPException(status_code= 400, detail= "Verification code has expired. Please request a new one.") |
| |
| |
| user.is_verified = True |
| user.verification_code = None |
| user.verification_code_expires_at = None |
| |
| db.add(user) |
| await db.commit() |
| await db.refresh(user) |
| |
| token = create_access_token(data={"sub": str(user.id)}) |
| return { |
| "access_token": token, |
| "token_type": "bearer", |
| "verification_required": False, |
| "email": user.email |
| } |
|
|
| @router.post("/auth/resend-code") |
| async def resend_code(payload: schemas.ResendCodeRequest, db: AsyncSession= Depends(get_db)): |
| """ |
| Generates and resends a new 6-digit OTP code to the user's email. |
| """ |
| user = await crud.get_user_by_email(db, payload.email) |
| if not user: |
| raise HTTPException(status_code= 400, detail= "Invalid request.") |
| |
| if user.is_verified: |
| raise HTTPException(status_code= 400, detail= "Email is already verified.") |
| |
| code = f"{random.randint(100000, 999999)}" |
| expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15) |
| |
| user.verification_code = code |
| user.verification_code_expires_at = expires_at |
| |
| db.add(user) |
| await db.commit() |
| await db.refresh(user) |
| |
| try: |
| from backend.app.services.celery_app import send_verification_email_task |
| send_verification_email_task.delay(user.email, code) |
| except Exception as email_err: |
| print(f"Failed to trigger verification email Celery task: {email_err}") |
| |
| return {"success": True} |
|
|
| @router.get("/auth/test-email") |
| async def test_email(email: str = None, delete_email: str = None, db: AsyncSession = Depends(get_db)): |
| """ |
| Diagnostic GET endpoint to check SMTP settings and send a test email. |
| Also supports deleting a test user from the database to reset signup testing. |
| """ |
| if delete_email: |
| user = await crud.get_user_by_email(db, delete_email) |
| if user: |
| await db.delete(user) |
| await db.commit() |
| return { |
| "success": True, |
| "message": f"User '{delete_email}' deleted successfully from database to reset signup testing." |
| } |
| else: |
| return { |
| "success": False, |
| "message": f"User '{delete_email}' not found in database." |
| } |
|
|
| to_email = email or settings.DEVELOPER_EMAIL |
| if not to_email: |
| to_email = "karansheler146@gmail.com" |
| |
| from backend.app.services.email_service import send_verification_email |
| import traceback |
| |
| config_status = { |
| "SMTP_HOST": settings.SMTP_HOST, |
| "SMTP_PORT": settings.SMTP_PORT, |
| "SMTP_USER": settings.SMTP_USER, |
| "SMTP_FROM": settings.SMTP_FROM, |
| "SMTP_PASSWORD_SET": bool(settings.SMTP_PASSWORD), |
| "TARGET_EMAIL": to_email |
| } |
| |
| if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD: |
| return { |
| "success": False, |
| "message": "SMTP settings are incomplete. Verify they are set in Hugging Face secrets.", |
| "config": config_status |
| } |
| |
| try: |
| |
| result = send_verification_email(to_email, "123456", raise_on_error=True) |
| if result: |
| return { |
| "success": True, |
| "message": f"Test email sent successfully to {to_email}!", |
| "config": config_status |
| } |
| else: |
| return { |
| "success": False, |
| "message": "Failed to send email. SMTP returned False (check logs).", |
| "config": config_status |
| } |
| except Exception as e: |
| error_tb = traceback.format_exc() |
| return { |
| "success": False, |
| "message": f"SMTP Exception: {str(e)}", |
| "traceback": error_tb, |
| "config": config_status |
| } |
|
|
| @router.get("/auth/model-metrics") |
| async def get_model_metrics(db: AsyncSession = Depends(get_db)): |
| """ |
| Computes real-time MLOps prediction statistics from the database. |
| """ |
| from sqlalchemy import select, func, case |
| |
| |
| total_result = await db.execute(select(func.count(models.PredictionLog.id))) |
| total_count = total_result.scalar() or 0 |
| |
| completed_result = await db.execute( |
| select(func.count(models.PredictionLog.id)) |
| .where(models.PredictionLog.status == "completed") |
| ) |
| completed_count = completed_result.scalar() or 0 |
| |
| success_result = await db.execute( |
| select(func.count(models.PredictionLog.id)) |
| .where(models.PredictionLog.status == "completed") |
| .where(models.PredictionLog.outcome == "success") |
| ) |
| success_count = success_result.scalar() or 0 |
| |
| |
| win_rate = (success_count / completed_count * 100.0) if completed_count > 0 else 0.0 |
| |
| |
| pnl_result = await db.execute( |
| select(func.avg(models.PredictionLog.pnl)) |
| .where(models.PredictionLog.status == "completed") |
| ) |
| avg_pnl = pnl_result.scalar() or 0.0 |
| |
| |
| version_stmt = ( |
| select( |
| models.PredictionLog.model_version, |
| func.count(models.PredictionLog.id).label("total"), |
| func.sum(case((models.PredictionLog.outcome == "success", 1), else_=0)).label("successes"), |
| func.avg(models.PredictionLog.pnl).label("avg_pnl") |
| ) |
| .where(models.PredictionLog.status == "completed") |
| .group_by(models.PredictionLog.model_version) |
| ) |
| |
| version_results = await db.execute(version_stmt) |
| by_version = [] |
| for row in version_results: |
| total = row.total or 0 |
| successes = row.successes or 0 |
| wr = (successes / total * 100.0) if total > 0 else 0.0 |
| by_version.append({ |
| "model_version": row.model_version, |
| "total_predictions": total, |
| "success_rate": round(wr, 2), |
| "average_pnl": round(row.avg_pnl or 0.0, 4) |
| }) |
| |
| |
| logs_result = await db.execute( |
| select(models.PredictionLog) |
| .order_by(models.PredictionLog.timestamp.desc()) |
| .limit(15) |
| ) |
| logs = list(logs_result.scalars().all()) |
| |
| |
| strat_total_result = await db.execute(select(func.count(models.StrategyLog.id))) |
| strat_total = strat_total_result.scalar() or 0 |
| |
| strat_completed_result = await db.execute( |
| select(func.count(models.StrategyLog.id)) |
| .where(models.StrategyLog.status == "completed") |
| ) |
| strat_completed = strat_completed_result.scalar() or 0 |
| |
| ai_success_result = await db.execute( |
| select(func.count(models.StrategyLog.id)) |
| .where(models.StrategyLog.status == "completed") |
| .where(models.StrategyLog.ai_outcome == "success") |
| ) |
| ai_success = ai_success_result.scalar() or 0 |
| |
| user_success_result = await db.execute( |
| select(func.count(models.StrategyLog.id)) |
| .where(models.StrategyLog.status == "completed") |
| .where(models.StrategyLog.user_outcome == "success") |
| ) |
| user_success = user_success_result.scalar() or 0 |
| |
| ai_win_rate = (ai_success / strat_completed * 100.0) if strat_completed > 0 else 0.0 |
| user_win_rate = (user_success / strat_completed * 100.0) if strat_completed > 0 else 0.0 |
| |
| |
| deviation_result = await db.execute( |
| select(func.avg(models.StrategyLog.user_target - models.StrategyLog.ai_target)) |
| .where(models.StrategyLog.status == "completed") |
| ) |
| avg_deviation = deviation_result.scalar() or 0.0 |
| |
| return { |
| "summary": { |
| "total_predictions": total_count, |
| "completed_evaluations": completed_count, |
| "successful_predictions": success_count, |
| "global_win_rate_percent": round(win_rate, 2), |
| "global_average_pnl_percent": round(avg_pnl, 4) |
| }, |
| "performance_by_model_version": by_version, |
| "strategy_performance_tracker": { |
| "total_strategies_locked": strat_total, |
| "completed_strategy_evaluations": strat_completed, |
| "ai_strategy_win_rate_percent": round(ai_win_rate, 2), |
| "user_strategy_win_rate_percent": round(user_win_rate, 2), |
| "average_user_target_price_deviation": round(avg_deviation, 4) |
| }, |
| "recent_predictions": [ |
| { |
| "id": str(log.id), |
| "ticker": log.ticker, |
| "timestamp": log.timestamp.isoformat(), |
| "model_version": log.model_version, |
| "confidence": round(log.confidence, 4), |
| "predicted_action": log.predicted_action, |
| "entry_price": log.entry_price, |
| "actual_price_1h": log.actual_price_1h, |
| "status": log.status, |
| "outcome": log.outcome, |
| "pnl_percent": round(log.pnl or 0.0, 4) if log.pnl else None |
| } |
| for log in logs |
| ] |
| } |
|
|
| @router.post("/payments/webhook") |
| async def razorpay_webhook(request: Request, x_razorpay_signature: str= Header(None), db: AsyncSession= Depends(get_db)): |
| """ |
| Listens for Razorpay payment captured webhook events. |
| Verifies signature and updates user credit balance asynchronously. |
| """ |
| if not x_razorpay_signature: |
| payment_callbacks_total.labels(package="unknown", status="failed").inc() |
| raise HTTPException(status_code= 400, detail= "Missing X-Razorpay-Signature header") |
| body= await request.body() |
| |
| |
| if settings.RAZORPAY_WEBHOOK_SECRET: |
| expected_signature= hmac.new( |
| settings.RAZORPAY_WEBHOOK_SECRET.encode("utf-8"), |
| body, |
| hashlib.sha256 |
| ).hexdigest() |
| |
| if not hmac.compare_digest(expected_signature, x_razorpay_signature): |
| payment_callbacks_total.labels(package="unknown", status="failed").inc() |
| raise HTTPException(status_code= 400, detail= "Invalid webhook signature") |
| |
| try: |
| payload= json.loads(body.decode("utf-8")) |
| event= payload.get("event") |
| |
| if event == "payment.captured": |
| payment_entity= payload["payload"] ["payment"] ["entity"] |
| order_id= payment_entity.get("order_id") |
| payment_id= payment_entity.get("id") |
| raw_amount = payment_entity.get("amount", 0) |
| amount = raw_amount // 100 |
| |
| package_name = "unknown" |
| if amount == 500: |
| package_name = "analyst" |
| elif amount == 1500: |
| package_name = "trader" |
| elif amount in (10000, 15000): |
| package_name = "pro" |
| |
| if order_id and payment_id: |
| |
| tx= await crud.capture_payment_transaction(db, order_id, payment_id) |
| |
| if tx: |
| print(f"Razorpay Webhook: Captured Order {order_id} | Payment {payment_id}") |
| payment_callbacks_total.labels(package=package_name, status="success").inc() |
| else: |
| payment_callbacks_total.labels(package=package_name, status="failed").inc() |
| else: |
| payment_callbacks_total.labels(package=package_name, status="failed").inc() |
| except Exception as e: |
| payment_callbacks_total.labels(package="unknown", status="failed").inc() |
| raise HTTPException(status_code= 500, detail= f"Webhook processing error: {str(e)}") |
| |
| return {"status": "ok"} |
|
|
|
|
| class ContactFormRequest(BaseModel): |
| name: str |
| email: str |
| message: str |
|
|
| @router.post("/contact") |
| async def contact_developer(payload: ContactFormRequest): |
| """ |
| Receives a message from the contact form and sends an email via SMTP. |
| Falls back to server console logging if SMTP credentials are not set. |
| """ |
| name = payload.name.strip() |
| email_addr = payload.email.strip() |
| message = payload.message.strip() |
| |
| if not name or not email_addr or not message: |
| raise HTTPException(status_code=400, detail="Name, email, and message are required.") |
| |
| |
| if settings.SMTP_HOST and settings.SMTP_USER and settings.SMTP_PASSWORD: |
| try: |
| |
| msg = MIMEMultipart() |
| msg["From"] = settings.SMTP_FROM |
| msg["To"] = settings.DEVELOPER_EMAIL |
| msg["Subject"] = f"QuantIQ Developer Contact: {name}" |
| |
| body = ( |
| f"You have received a new contact message from QuantIQ:\n\n" |
| f"Name: {name}\n" |
| f"Email: {email_addr}\n\n" |
| f"Message:\n{message}\n" |
| ) |
| msg.attach(MIMEText(body, "plain")) |
| |
| |
| server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) |
| server.starttls() |
| server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) |
| server.sendmail(settings.SMTP_FROM, settings.DEVELOPER_EMAIL, msg.as_string()) |
| server.quit() |
| |
| print(f"Contact Form: Email successfully sent to {settings.DEVELOPER_EMAIL} from {email_addr}") |
| except Exception as e: |
| |
| print(f"Contact Form Error: Failed to send email via SMTP: {str(e)}") |
| print(f"FALLBACK CONTACT MESSAGE:\nName: {name}\nEmail: {email_addr}\nMessage: {message}") |
| else: |
| |
| print("Contact Form: SMTP not configured. Logging contact form submission:") |
| print(f"Name: {name}") |
| print(f"Email: {email_addr}") |
| print(f"Message: {message}") |
| |
| return {"status": "success", "message": "Your message has been sent successfully."} |
|
|
| @router.post("/users/avatar") |
| async def upload_user_avatar(file: UploadFile= File(...), current_user: models.User= Depends(get_current_user), db: AsyncSession= Depends(get_db)): |
| """ |
| Uploads a new user profile avatar to Cloudinary, updates it in the database, and returns the new picture URL. |
| """ |
| if not file.content_type.startswith("image/"): |
| raise HTTPException(status_code= 400, detail= "Uploaded file must be an image.") |
| |
| try: |
| file_bytes= await file.read() |
| secure_url= upload_avatar(file_bytes, str(current_user.id)) |
| |
| current_user.picture_url= secure_url |
| db.add(current_user) |
| await db.commit() |
| await db.refresh(current_user) |
| |
| return {"picture_url": secure_url} |
| except ValueError as ve: |
| raise HTTPException(status_code= 400, detail= str(ve)) |
| except Exception as e: |
| raise HTTPException(status_code= 500, detail= f"Failed to upload avatar: {str(e)}") |
|
|
| @router.get("/stocks/search") |
| async def search_stocks(q: str): |
| """ |
| Proxies Yahoo Finance's autocomplete API to suggest matching stock tickers. |
| """ |
| if not q or len(q.strip()) < 2: |
| return [] |
| |
| query = q.strip() |
| url = f"https://query1.finance.yahoo.com/v1/finance/search?q={query}"esCount=8&newsCount=0" |
| |
| headers = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" |
| } |
| |
| async with httpx.AsyncClient() as client: |
| try: |
| response = await client.get(url, headers=headers, timeout=5.0) |
| if response.status_code != 200: |
| return [] |
| |
| data = response.json() |
| quotes = data.get("quotes", []) |
| |
| results = [] |
| for quote in quotes: |
| symbol = quote.get("symbol") |
| quote_type = quote.get("quoteType") |
| name = quote.get("shortname") or quote.get("longname") or symbol |
| |
| |
| if symbol and quote_type in ["EQUITY", "ETF", "CRYPTOCURRENCY", "INDEX"]: |
| results.append({ |
| "symbol": symbol, |
| "name": name |
| }) |
| return results |
| except Exception as e: |
| print(f"Error querying Yahoo Finance search API: {str(e)}") |
| return [] |
|
|
| |
| _indices_cache = { |
| "data": None, |
| "timestamp": None |
| } |
|
|
| @router.get("/stocks/indices") |
| async def get_global_indices(): |
| """ |
| Returns live prices and 24h changes for major global indices and assets: |
| - S&P 500 (^GSPC) |
| - NASDAQ (^IXIC) |
| - Nifty 50 (^NSEI) |
| - Bitcoin (BTC-USD) |
| - Gold (GC=F) |
| """ |
| global _indices_cache |
| now = datetime.datetime.now(datetime.timezone.utc) |
| |
| |
| if _indices_cache["data"] and _indices_cache["timestamp"]: |
| if now - _indices_cache["timestamp"] < datetime.timedelta(seconds=60): |
| return _indices_cache["data"] |
| |
| tickers = ["^GSPC", "^IXIC", "^NSEI", "BTC-USD", "GC=F"] |
| names_map = { |
| "^GSPC": "S&P 500", |
| "^IXIC": "NASDAQ", |
| "^NSEI": "Nifty 50", |
| "BTC-USD": "Bitcoin", |
| "GC=F": "Gold" |
| } |
| |
| results = [] |
| |
| try: |
| import yfinance as yf |
| import pandas as pd |
| import asyncio |
| loop = asyncio.get_event_loop() |
| |
| |
| try: |
| df = await loop.run_in_executor( |
| None, |
| lambda: yf.download(tickers=" ".join(tickers), period="2d", group_by="ticker", progress=False) |
| ) |
| external_api_calls_total.labels(provider="yfinance", status="success").inc() |
| except Exception as batch_err: |
| external_api_calls_total.labels(provider="yfinance", status="failed").inc() |
| raise batch_err |
| |
| for symbol in tickers: |
| name = names_map[symbol] |
| price = 0.0 |
| change_percent = 0.0 |
| |
| try: |
| |
| if isinstance(df.columns, pd.MultiIndex): |
| ticker_df = df[symbol].dropna(subset=["Close"]) |
| else: |
| ticker_df = df.dropna(subset=["Close"]) |
| |
| if not ticker_df.empty: |
| close_series = ticker_df["Close"] |
| open_series = ticker_df["Open"] |
| |
| if len(close_series) >= 2: |
| curr = float(close_series.iloc[-1]) |
| prev = float(close_series.iloc[-2]) |
| price = curr |
| change_percent = ((curr - prev) / prev) * 100 |
| elif len(close_series) == 1: |
| curr = float(close_series.iloc[-1]) |
| op = float(open_series.iloc[-1]) if not open_series.empty else curr |
| price = curr |
| change_percent = ((curr - op) / op) * 100 if op != 0 else 0.0 |
| except Exception as inner_e: |
| print(f"Error parsing index data for {symbol}: {inner_e}") |
| |
| results.append({ |
| "symbol": symbol, |
| "name": name, |
| "price": round(price, 2), |
| "changePercent": round(change_percent, 2) |
| }) |
| |
| except Exception as e: |
| print(f"Error downloading global indices from yfinance: {e}") |
| |
| results = [ |
| {"symbol": "^GSPC", "name": "S&P 500", "price": 5475.90, "changePercent": 0.35}, |
| {"symbol": "^IXIC", "name": "NASDAQ", "price": 17822.60, "changePercent": 0.42}, |
| {"symbol": "^NSEI", "name": "Nifty 50", "price": 23512.60, "changePercent": 0.60}, |
| {"symbol": "BTC-USD", "name": "Bitcoin", "price": 64320.50, "changePercent": -1.25}, |
| {"symbol": "GC=F", "name": "Gold", "price": 2332.10, "changePercent": 0.66} |
| ] |
| |
| _indices_cache = { |
| "data": results, |
| "timestamp": now |
| } |
| return results |
|
|
| _trending_cache = {} |
|
|
| @router.get("/stocks/trending") |
| async def get_trending_assets(): |
| global _trending_cache |
| now = datetime.datetime.now(datetime.timezone.utc) |
| |
| if "data" in _trending_cache and _trending_cache["timestamp"]: |
| if now - _trending_cache["timestamp"] < datetime.timedelta(seconds=60): |
| return _trending_cache["data"] |
| |
| |
| tickers = ["BTC-USD", "ETH-USD", "NVDA", "AAPL", "TSLA", "SOL-USD", "MSFT", "AMZN", "NFLX", "GOOGL"] |
| names_map = { |
| "BTC-USD": "Bitcoin", |
| "ETH-USD": "Ethereum", |
| "NVDA": "NVIDIA Corp.", |
| "AAPL": "Apple Inc.", |
| "TSLA": "Tesla Inc.", |
| "SOL-USD": "Solana", |
| "MSFT": "Microsoft Corp.", |
| "AMZN": "Amazon.com Inc.", |
| "NFLX": "Netflix Inc.", |
| "GOOGL": "Alphabet Inc." |
| } |
| categories_map = { |
| "BTC-USD": "Crypto", |
| "ETH-USD": "Crypto", |
| "NVDA": "Stock", |
| "AAPL": "Stock", |
| "TSLA": "Stock", |
| "SOL-USD": "Crypto", |
| "MSFT": "Stock", |
| "AMZN": "Stock", |
| "NFLX": "Stock", |
| "GOOGL": "Stock" |
| } |
| |
| results = [] |
| try: |
| import yfinance as yf |
| import asyncio |
| import pandas as pd |
| |
| loop = asyncio.get_event_loop() |
| df = await loop.run_in_executor( |
| None, |
| lambda: yf.download(tickers=" ".join(tickers), period="2d", group_by="ticker", progress=False) |
| ) |
| |
| for symbol in tickers: |
| name = names_map[symbol] |
| category = categories_map[symbol] |
| price = 0.0 |
| change_percent = 0.0 |
| |
| try: |
| if isinstance(df.columns, pd.MultiIndex): |
| ticker_df = df[symbol].dropna(subset=["Close"]) |
| else: |
| ticker_df = df.dropna(subset=["Close"]) |
| |
| if not ticker_df.empty: |
| close_series = ticker_df["Close"] |
| open_series = ticker_df["Open"] |
| |
| if len(close_series) >= 2: |
| curr = float(close_series.iloc[-1]) |
| prev = float(close_series.iloc[-2]) |
| price = curr |
| change_percent = ((curr - prev) / prev) * 100 |
| elif len(close_series) == 1: |
| curr = float(close_series.iloc[-1]) |
| op = float(open_series.iloc[-1]) if not open_series.empty else curr |
| price = curr |
| change_percent = ((curr - op) / op) * 100 if op != 0 else 0.0 |
| except Exception as inner_e: |
| print(f"Error parsing trending data for {symbol}: {inner_e}") |
| |
| results.append({ |
| "symbol": symbol, |
| "name": name, |
| "price": round(price, 2), |
| "change": round(change_percent, 2), |
| "category": category |
| }) |
| |
| |
| results.sort(key=lambda x: abs(x["change"]), reverse=True) |
| |
| except Exception as e: |
| print(f"Error fetching trending assets: {e}") |
| |
| results = [ |
| { "symbol": 'BTC-USD', "name": 'Bitcoin', "price": 60534.05, "change": 1.82, "category": 'Crypto' }, |
| { "symbol": 'ETH-USD', "name": 'Ethereum', "price": 3421.10, "change": 0.54, "category": 'Crypto' }, |
| { "symbol": 'NVDA', "name": 'NVIDIA Corp.', "price": 121.40, "change": -1.25, "category": 'Stock' }, |
| { "symbol": 'AAPL', "name": 'Apple Inc.', "price": 210.62, "change": 0.95, "category": 'Stock' }, |
| { "symbol": 'TSLA', "name": 'Tesla Inc.', "price": 187.30, "change": 4.12, "category": 'Stock' }, |
| { "symbol": 'SOL-USD', "name": 'Solana', "price": 142.15, "change": 3.85, "category": 'Crypto' }, |
| ] |
| |
| _trending_cache = { |
| "data": results, |
| "timestamp": now |
| } |
| return results |
|
|
| @router.get("/stocks/news") |
| async def get_market_news(): |
| """ |
| Fetches live financial news from Yahoo Finance API. |
| """ |
| url = "https://query1.finance.yahoo.com/v1/finance/search?q=market"esCount=0&newsCount=8" |
| headers = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" |
| } |
| |
| async with httpx.AsyncClient() as client: |
| try: |
| response = await client.get(url, headers=headers, timeout=5.0) |
| if response.status_code != 200: |
| return [] |
| |
| data = response.json() |
| news_items = data.get("news", []) |
| |
| results = [] |
| for item in news_items: |
| title = item.get("title") |
| link = item.get("link") |
| publisher = item.get("publisher") or "Yahoo Finance" |
| publish_time = item.get("providerPublishTime") |
| |
| time_str = "Recent" |
| if publish_time: |
| import time |
| diff = int(time.time()) - publish_time |
| if diff < 3600: |
| time_str = f"{max(1, diff // 60)}m ago" |
| elif diff < 86400: |
| time_str = f"{diff // 3600}h ago" |
| else: |
| time_str = f"{diff // 86400}d ago" |
| |
| import uuid |
| results.append({ |
| "id": item.get("uuid") or str(uuid.uuid4()), |
| "title": title, |
| "summary": f"Latest updates, corporate developments, and global analyst reporting.", |
| "source": publisher, |
| "time": time_str, |
| "category": "Markets", |
| "link": link |
| }) |
| return results |
| except Exception as e: |
| print("Failed to fetch market news:", e) |
| return [] |
|
|
| _market_movers_cache = {} |
|
|
| @router.get("/stocks/market-movers") |
| async def get_market_movers(): |
| """ |
| Fetches Top Gainers, Top Losers, and Most Active stocks from Yahoo Finance screener. |
| Cached for 60 seconds to avoid rate limiting. |
| """ |
| global _market_movers_cache |
| import datetime |
| now = datetime.datetime.now(datetime.timezone.utc) |
| |
| if "data" in _market_movers_cache and _market_movers_cache.get("timestamp"): |
| if now - _market_movers_cache["timestamp"] < datetime.timedelta(seconds=60): |
| return _market_movers_cache["data"] |
| |
| headers = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", |
| "Accept": "application/json" |
| } |
| |
| async def fetch_screener(scr_id: str, count: int = 5): |
| url = f"https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?formatted=false&lang=en-US®ion=US&scrIds={scr_id}&count={count}" |
| try: |
| async with httpx.AsyncClient(timeout=8.0) as client: |
| res = await client.get(url, headers=headers) |
| if res.status_code != 200: |
| return [] |
| data = res.json() |
| quotes = data.get("finance", {}).get("result", [{}])[0].get("quotes", []) |
| results = [] |
| for q in quotes: |
| symbol = q.get("symbol", "") |
| name = q.get("shortName") or q.get("longName") or symbol |
| price = q.get("regularMarketPrice", 0.0) |
| change = q.get("regularMarketChange", 0.0) |
| change_pct = q.get("regularMarketChangePercent", 0.0) |
| results.append({ |
| "symbol": symbol, |
| "name": name[:22] + "..." if len(name) > 22 else name, |
| "price": round(float(price), 2), |
| "change": round(float(change), 2), |
| "changePercent": round(float(change_pct), 2) |
| }) |
| return results |
| except Exception as e: |
| print(f"Error fetching screener {scr_id}: {e}") |
| return [] |
| |
| gainers, losers, most_active = await asyncio.gather( |
| fetch_screener("day_gainers"), |
| fetch_screener("day_losers"), |
| fetch_screener("most_actives") |
| ) |
| |
| |
| if not gainers: |
| gainers = [ |
| {"symbol": "SLBT", "name": "SL Science Holding", "price": 5.99, "change": 1.54, "changePercent": 34.61}, |
| {"symbol": "PLBL", "name": "Polibeli Group Ltd", "price": 10.26, "change": 1.58, "changePercent": 18.20}, |
| {"symbol": "GPC", "name": "Genuine Parts Co.", "price": 132.57, "change": 15.17, "changePercent": 12.92}, |
| {"symbol": "SLS", "name": "SELLAS Life Sciences", "price": 14.98, "change": 1.71, "changePercent": 12.89}, |
| {"symbol": "CAR", "name": "Avis Budget Group", "price": 163.44, "change": 16.50, "changePercent": 11.23}, |
| ] |
| if not losers: |
| losers = [ |
| {"symbol": "RGC", "name": "Regencell Bioscience", "price": 6.37, "change": -1.66, "changePercent": -20.67}, |
| {"symbol": "VICR", "name": "Vicor Corporation", "price": 282.95, "change": -67.26, "changePercent": -19.21}, |
| {"symbol": "ACLS", "name": "Axcelis Technologies", "price": 144.50, "change": -33.83, "changePercent": -18.97}, |
| {"symbol": "VECO", "name": "Veeco Instruments", "price": 57.49, "change": -13.03, "changePercent": -18.48}, |
| {"symbol": "BELFA", "name": "Bel Fuse Inc.", "price": 230.16, "change": -51.51, "changePercent": -18.29}, |
| ] |
| if not most_active: |
| most_active = [ |
| {"symbol": "AAL", "name": "American Airlines", "price": 17.92, "change": -0.23, "changePercent": -1.27}, |
| {"symbol": "T", "name": "AT&T Inc.", "price": 20.58, "change": 0.10, "changePercent": 0.49}, |
| {"symbol": "NVDA", "name": "NVIDIA Corporation", "price": 194.83, "change": -2.75, "changePercent": -1.39}, |
| {"symbol": "INTC", "name": "Intel Corporation", "price": 120.35, "change": -6.67, "changePercent": -5.25}, |
| {"symbol": "OPEN", "name": "Opendoor Technologies", "price": 4.90, "change": -0.04, "changePercent": -0.81}, |
| ] |
| |
| result = {"gainers": gainers, "losers": losers, "most_active": most_active} |
| _market_movers_cache = {"data": result, "timestamp": now} |
| return result |
|
|
| from typing import List, Dict, Any, Optional |
|
|
| class ChatRequest(BaseModel): |
| ticker: str |
| message: str |
| history: List[Dict[str, str]] |
| markers: List[Dict[str, Any]] |
| activeIndicators: Dict[str, bool] |
| currentPrice: Optional[float] = None |
| smaValue: Optional[float] = None |
| emaValue: Optional[float] = None |
| rsiValue: Optional[float] = None |
|
|
| @router.post("/analyst/chat") |
| async def chat_with_analyst( |
| payload: ChatRequest, |
| current_user: models.User = Depends(get_current_user), |
| db: AsyncSession = Depends(get_db) |
| ): |
| """ |
| Takes the user's message, drawing markers, active indicators, and chat history, |
| and returns a cooperative analysis message from Gemini. |
| Enforces subscription message limits. |
| """ |
| |
| user = await crud.refresh_user_credits(db, current_user) |
| is_admin = (user.email == "karanshelar8775@gmail.com") |
|
|
| |
| if not is_admin: |
| if user.subscription_tier in ("free", "analyst", "trader"): |
| if user.messages_remaining <= 0: |
| raise HTTPException(status_code=403, detail="Quota exhausted. Please upgrade your plan.") |
| elif user.subscription_tier == "pro": |
| if user.monthly_messages_used >= 100: |
| raise HTTPException(status_code=403, detail="Monthly message quota of 100 exhausted.") |
|
|
| ticker = payload.ticker |
| message = payload.message |
| history = payload.history |
| markers = payload.markers |
| active_indicators = payload.activeIndicators |
| current_price = payload.currentPrice |
| sma_val = payload.smaValue |
| ema_val = payload.emaValue |
| rsi_val = payload.rsiValue |
|
|
| live_data_text = "" |
| if current_price is not None: |
| live_data_text += f"- Exact Live Stock Price: ${current_price}\n" |
| if sma_val is not None: |
| live_data_text += f"- Live SMA 20 Line Value: ${sma_val}\n" |
| if ema_val is not None: |
| live_data_text += f"- Live EMA 20 Line Value: ${ema_val}\n" |
| if rsi_val is not None: |
| live_data_text += f"- Live RSI 14 Value: {rsi_val}\n" |
|
|
| |
| markers_text = "" |
| if markers: |
| markers_text = "\n".join([f"- Level: ${m.get('price')} ({m.get('label') or 'Unnamed'})" for m in markers]) |
| else: |
| markers_text = "No custom price level markers have been drawn on the chart." |
|
|
| |
| from backend.app.services.gemini import get_onnx_prediction |
| probability_score = await get_onnx_prediction(db, ticker) |
| |
| |
| indicators_list = [] |
| if active_indicators.get("sma"): |
| indicators_list.append("SMA 20 Overlay") |
| if active_indicators.get("ema"): |
| indicators_list.append("EMA 20 Overlay") |
| if active_indicators.get("rsi"): |
| indicators_list.append("RSI 14 Panel") |
| indicators_text = ", ".join(indicators_list) if indicators_list else "None" |
| |
| |
| system_prompt = ( |
| "You are the QuantIQ Cooperative AI Strategy Advisor. A trader is chatting with you " |
| f"while viewing a live chart for {ticker}.\n\n" |
| "Here is the context of their active workspace (refer to these exact values, DO NOT state that you cannot see their screen or use hypothetical placeholders):\n" |
| f"- Active Ticker: {ticker}\n" |
| f"{live_data_text}" |
| f"- Active Indicators on Screen: {indicators_text}\n" |
| f"- Trader's Custom reference level markers drawn on the canvas:\n{markers_text}\n" |
| f"- Coordinated ML Model Bullish Probability: {probability_score}%\n\n" |
| "Guidelines:\n" |
| "1. Act as a professional quantitative mentor. Evaluate their drawn levels (e.g. entry, target, stop loss) " |
| "relative to the stock price context, indicators, and your previous recommendations.\n" |
| "2. Check the chat history below. If you previously suggested specific levels, look at the trader's current markers " |
| "and compare them. If they match your recommendation, acknowledge it (e.g. 'I see you set your entry at X and stop loss at Y as suggested'). " |
| "If their markers differ or are incorrect (e.g. profit target equal to or below entry), politely point it out, explain the mistake, " |
| "and guide them to adjust their markers back to your recommended levels.\n" |
| "3. When evaluating exit/entry points, you MUST provide a clearly formatted subtopic starting with a heading like '### **Optimized Entry & Exit Points**'. " |
| "Inside this section, explicitly list and highlight the Entry Price, Stop-Loss Level, and Profit Target Price in bold. " |
| "Help the user identify where to place their chart markers to maximize profit and protect their capital.\n" |
| "4. Provide extremely detailed, thorough, comprehensive, and complete answers. Do not summarize or leave out details. " |
| "Break down your explanation step-by-step so that absolutely no doubt is left in the trader's mind.\n" |
| "5. Use very simple, layman, easy-to-understand language. Avoid overly complex terminology without explaining it simply first. " |
| "Ensure any beginner trader can follow your strategy critique.\n" |
| "6. Provide realistic risk-to-reward ratios and volatility warnings based on the asset.\n" |
| "7. At the very end of your response, you MUST provide a final section called '**Probability Prediction Score:**'. " |
| "Output the score as exactly: '**Probability Prediction Score:** {probability_score}% Bullish (Coordinated with QuantIQ ML Model)'. " |
| "Add a 1-sentence simplified explanation of why the ML model outputs this probability based on current indicator alignment.\n" |
| "8. Strictly refuse to answer any questions that are not directly related to financial markets, trading, stock exchanges, " |
| "crypto, technical indicators, or custom price levels. If the user asks about anything else (e.g. general history, unrelated coding, " |
| "cooking, sports, life advice, general science), you must politely reject the query, state that you are optimized solely for " |
| "trading strategy and market analysis, and divert the conversation back to the active chart analysis or custom markers. Be friendly but firm.\n\n" |
| "Below is the conversation history and the user's latest question. Respond to their latest question directly." |
| ) |
|
|
| |
| formatted_history = "" |
| for turn in history[-30:]: |
| role = "Trader" if turn.get("role") == "user" else "Advisor" |
| formatted_history += f"\n{role}: {turn.get('content')}" |
|
|
| full_prompt = f"{system_prompt}\n\nChat History:{formatted_history}\nTrader: {message}\nAdvisor:" |
|
|
| from backend.app.services.gemini import generate_text |
| response_text = await generate_text(full_prompt) |
|
|
| |
| if not is_admin: |
| if user.subscription_tier in ("free", "analyst", "trader"): |
| user.messages_remaining = max(0, user.messages_remaining - 1) |
| elif user.subscription_tier == "pro": |
| user.monthly_messages_used += 1 |
| |
| db.add(user) |
| await db.commit() |
| await db.refresh(user) |
|
|
| return { |
| "response": response_text, |
| "subscription_tier": user.subscription_tier, |
| "messages_remaining": user.messages_remaining, |
| "monthly_messages_used": user.monthly_messages_used |
| } |
|
|
|
|
| @router.get("/ml/metadata") |
| async def get_ml_model_metadata(): |
| """ |
| Get ML model metadata including last retrained timestamp and model information. |
| Returns the custom metadata from the ONNX model session. |
| """ |
| try: |
| |
| session = get_onnx_session_for_type("tech") |
|
|
| if session is None: |
| |
| return { |
| "trained_date": None, |
| "features": [], |
| "accuracy": None, |
| "model_type": "tech", |
| "status": "model_not_loaded" |
| } |
|
|
| |
| meta = session.get_modelmeta() |
| custom_metadata_map = getattr(meta, 'custom_metadata_map', {}) or {} |
| |
| if not isinstance(custom_metadata_map, dict): |
| custom_metadata_map = {} |
|
|
| |
| trained_date = custom_metadata_map.get('trained_date') or custom_metadata_map.get('timestamp') or custom_metadata_map.get('date') |
| features_str = custom_metadata_map.get('features') or custom_metadata_map.get('feature_names') or '' |
| accuracy = custom_metadata_map.get('accuracy') or custom_metadata_map.get('acc') or custom_metadata_map.get('score') |
|
|
| |
| features = [] |
| if isinstance(features_str, str): |
| features = [f.strip() for f in features_str.split(',') if f.strip()] |
| elif isinstance(features_str, list): |
| features = [str(f).strip() for f in features_str if str(f).strip()] |
|
|
| |
| accuracy_float = None |
| if accuracy is not None: |
| try: |
| accuracy_float = float(accuracy) |
| except (ValueError, TypeError): |
| pass |
|
|
| return { |
| "trained_date": trained_date, |
| "features": features, |
| "accuracy": accuracy_float, |
| "model_type": "tech", |
| "status": "success" |
| } |
|
|
| except Exception as e: |
| |
| return { |
| "trained_date": None, |
| "features": [], |
| "accuracy": None, |
| "model_type": "tech", |
| "status": "error", |
| "error_message": str(e) |
| } |