Download backend/app/api/endpoints.py from Karan6124/QuantIQ: direct link, hf CLI and curl.
- Browser
- Download file 56 kB
-
https://huggingface.co/spaces/Karan6124/QuantIQ/resolve/main/backend/app/api/endpoints.py
- Command line
-
hf download hf://spaces/Karan6124/QuantIQ/backend/app/api/endpoints.py
-
curl -L -o endpoints.py https://huggingface.co/spaces/Karan6124/QuantIQ/resolve/main/backend/app/api/endpoints.py
56 kB
| 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, compute_chart_indicators | |
| router= APIRouter() | |
| # JWT TOKEN UTILITIES | |
| 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 | |
| # REST ENDPOINTS | |
| 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 | |
| # 1. Dev Mode: Bypass Google verification if mock token is sent | |
| 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"} | |
| # 2. Production Mode: Verify the ID Token via Google's tokeninfo API | |
| 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() | |
| # Verify the audience matches our client ID if one is configured | |
| 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)}") | |
| # 3. Register or Retrieve User | |
| user= await crud.get_user_by_google_id(db, google_id) | |
| if not user: | |
| # Check if email is already in use by a mock account | |
| 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) | |
| # 4. Generate and return our custom JWT | |
| access_token= create_access_token(data= {"sub": str(user.id)}) | |
| return {"access_token": access_token, "token_type": "bearer"} | |
| # TRADITIONAL EMAIL AUTHENTICATION | |
| from pydantic import BaseModel | |
| class EmailSignUpRequest(BaseModel): | |
| email:str | |
| full_name: str | |
| country: str | |
| password: str | |
| class EmailLoginRequest(BaseModel): | |
| email:str | |
| password: str | |
| 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. | |
| """ | |
| # 1. Check if email already in use | |
| existing_user= await crud.get_user_by_email(db, payload.email) | |
| # Store password hash in google_id (local:{hash}) so picture_url stays free for profile photos | |
| 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 it's already a verified LOCAL account, block re-registration | |
| if existing_user.google_id.startswith("local:"): | |
| raise HTTPException(status_code= 400, detail= "Email is already registered.") | |
| else: | |
| # Verified Google account trying to register with email/password | |
| raise HTTPException(status_code= 400, detail= "This email is linked to a Google account. Please sign in with Google.") | |
| else: | |
| # Reusing the existing unverified user record (updating password/profile details) | |
| existing_user.full_name = payload.full_name | |
| existing_user.google_id = google_id # update hash in google_id | |
| user = existing_user | |
| else: | |
| # Create a new unverified user record | |
| user_in= schemas.UserBase( | |
| email= payload.email, | |
| full_name= payload.full_name, | |
| picture_url= None # no profile pic yet for email signup | |
| ) | |
| 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)}") | |
| # 2. Generate a 6-digit verification code | |
| 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) | |
| # 3. Trigger verification email via Celery | |
| 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 | |
| } | |
| 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.") | |
| # Detect local (email/password) accounts by google_id starting with "local:" | |
| 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.") | |
| # Check if user is verified | |
| if not user.is_verified: | |
| # Regenerate and resend verification code to prevent lockout | |
| 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 | |
| } | |
| 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.") | |
| # Activate user | |
| 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 | |
| } | |
| 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} | |
| 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: | |
| # Run synchronous send directly to capture the exact exception stack trace | |
| 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 | |
| } | |
| 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_count = 0 | |
| completed_count = 0 | |
| success_count = 0 | |
| win_rate = 0.0 | |
| avg_pnl = 0.0 | |
| by_version = [] | |
| logs = [] | |
| strat_total = 0 | |
| strat_completed = 0 | |
| ai_win_rate = 0.0 | |
| user_win_rate = 0.0 | |
| avg_deviation = 0.0 | |
| # 1. Prediction log summary counts | |
| try: | |
| 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 | |
| except Exception as db_err1: | |
| print(f"Metrics warning (prediction counts): {db_err1}") | |
| # 2. Prediction log performance by version | |
| try: | |
| version_stmt = ( | |
| select( | |
| models.PredictionLog.model_version, | |
| func.count(models.PredictionLog.id).label("total"), | |
| 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) | |
| for row in version_results: | |
| total = row.total or 0 | |
| by_version.append({ | |
| "model_version": row.model_version or "v1.0.0", | |
| "total_predictions": total, | |
| "success_rate": 0.0, | |
| "average_pnl": round(float(row.avg_pnl or 0.0), 4) | |
| }) | |
| except Exception as db_err2: | |
| print(f"Metrics warning (version breakdown): {db_err2}") | |
| # 3. Recent prediction logs | |
| try: | |
| logs_result = await db.execute( | |
| select(models.PredictionLog) | |
| .order_by(models.PredictionLog.timestamp.desc()) | |
| .limit(15) | |
| ) | |
| logs = list(logs_result.scalars().all()) | |
| except Exception as db_err3: | |
| print(f"Metrics warning (recent logs): {db_err3}") | |
| # 4. Strategy Log Statistics (User vs AI comparison) | |
| try: | |
| 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 | |
| except Exception as db_err4: | |
| print(f"Metrics warning (strategy logs): {db_err4}") | |
| 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(float(avg_pnl or 0.0), 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(float(avg_deviation or 0.0), 4) | |
| }, | |
| "recent_predictions": [ | |
| { | |
| "id": str(log.id), | |
| "ticker": log.ticker, | |
| "timestamp": log.timestamp.isoformat() if hasattr(log.timestamp, "isoformat") else str(log.timestamp), | |
| "model_version": log.model_version, | |
| "confidence": round(float(log.confidence or 0.0), 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(float(log.pnl), 4) if log.pnl is not None else None | |
| } | |
| for log in logs | |
| ] | |
| } | |
| 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() | |
| # 1. Cryptographic signature check (skipped if secret is not set during local testing) | |
| 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: | |
| # Atomically update transaction status and user credit count | |
| 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 | |
| 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.") | |
| # Check if SMTP settings are configured | |
| if settings.SMTP_HOST and settings.SMTP_USER and settings.SMTP_PASSWORD: | |
| try: | |
| # Construct the email | |
| 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")) | |
| # Connect and send | |
| 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: | |
| # Log error but don't crash, fallback to printing the message | |
| 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: | |
| # Fallback logging if SMTP is not configured | |
| 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."} | |
| 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)}") | |
| 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 | |
| # Filter to only return actual tradeable assets | |
| 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 [] | |
| # Caching for global indices to prevent hitting yfinance too frequently | |
| _indices_cache = { | |
| "data": None, | |
| "timestamp": None | |
| } | |
| 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) | |
| # Cache hit check (60-second cache window) | |
| 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() | |
| # Download historical data for the last 2 days in a single batch request | |
| 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: | |
| # Handle DataFrame structure depending on whether multi-ticker format returned | |
| 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}") | |
| # Default mock fallback data if yfinance/network fails entirely | |
| 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 = {} | |
| 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"] | |
| # List of trending assets | |
| 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 | |
| }) | |
| # Sort by highest absolute price change percentage (the stocks that are "more trending") | |
| results.sort(key=lambda x: abs(x["change"]), reverse=True) | |
| except Exception as e: | |
| print(f"Error fetching trending assets: {e}") | |
| # fallback | |
| 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 | |
| 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 = {} | |
| 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") | |
| ) | |
| # Fallback data if API fails (weekend/market closed) | |
| 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 | |
| async def get_chart_indicators( | |
| ticker: str, | |
| range: str = "1d", | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """ | |
| Returns precomputed MACD (12/26/9) and Bollinger Bands (20, 2) series for the | |
| active chart range. Series are aligned by epoch timestamp with the candles | |
| served by the ``stockHistory`` GraphQL query, and computed with the exact same | |
| pandas-ta pipeline the backend ML model uses under the hood. | |
| Example: | |
| GET /api/v1/indicators?ticker=BTC-USD&range=1d | |
| """ | |
| try: | |
| return await compute_chart_indicators(db, ticker, range) | |
| except Exception as e: | |
| print(f"Error computing chart indicators for {ticker} ({range}): {e}") | |
| return { | |
| "macd": {"line": [], "signal": [], "histogram": []}, | |
| "bollinger_bands": {"upper": [], "middle": [], "lower": []}, | |
| "candle_count": 0, | |
| } | |
| 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 | |
| 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. | |
| """ | |
| # 1. Refresh user billing cycles / credits | |
| user = await crud.refresh_user_credits(db, current_user) | |
| is_admin = (user.email == "karanshelar8775@gmail.com") | |
| # 2. Check limits | |
| 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" | |
| # Format the markers list into text | |
| 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." | |
| # Calculate technical ML coordinated bullish probability score using the real ONNX helper | |
| from backend.app.services.gemini import get_onnx_prediction | |
| probability_score = await get_onnx_prediction(db, ticker) | |
| # Format indicators | |
| 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") | |
| if active_indicators.get("macd"): | |
| indicators_list.append("MACD (12, 26, 9) Panel") | |
| if active_indicators.get("bb"): | |
| indicators_list.append("Bollinger Bands (20, 2) Overlay") | |
| indicators_text = ", ".join(indicators_list) if indicators_list else "None" | |
| # Construct system instructions | |
| 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." | |
| ) | |
| # Format chat history | |
| formatted_history = "" | |
| for turn in history[-30:]: # Keep last 30 turns for long-term memory context | |
| 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) | |
| # 3. Deduct/Increment message counts | |
| 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 | |
| } | |
| 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: | |
| # Get the ONNX session for the default model type (tech) | |
| session = get_onnx_session_for_type("tech") | |
| if session is None: | |
| # Return default values if no session is available | |
| return { | |
| "trained_date": None, | |
| "features": [], | |
| "accuracy": None, | |
| "model_type": "tech", | |
| "status": "model_not_loaded" | |
| } | |
| # Get the model metadata | |
| meta = session.get_modelmeta() | |
| custom_metadata_map = getattr(meta, 'custom_metadata_map', {}) or {} | |
| # Ensure it's a dictionary | |
| if not isinstance(custom_metadata_map, dict): | |
| custom_metadata_map = {} | |
| # Extract and map the required fields with fallback defaults | |
| 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') | |
| # Parse features if it's a comma-separated string | |
| features = [] | |
| if isinstance(features_str, str) and features_str.strip(): | |
| features = [f.strip() for f in features_str.split(',') if f.strip()] | |
| elif isinstance(features_str, list) and len(features_str) > 0: | |
| features = [str(f).strip() for f in features_str if str(f).strip()] | |
| # Fallback defaults if ONNX header doesn't embed metadata dict | |
| if not features: | |
| features = ["RSI_14", "MACD_12_26_9", "MACDs_12_26_9", "EMA_20_ratio"] | |
| if not trained_date: | |
| import os, datetime | |
| model_file = "model_tech.onnx" if os.path.exists("model_tech.onnx") else ("model.onnx" if os.path.exists("model.onnx") else None) | |
| if model_file: | |
| mtime = os.path.getmtime(model_file) | |
| trained_date = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") | |
| # Convert accuracy to float if possible | |
| accuracy_float = None | |
| if accuracy is not None: | |
| try: | |
| accuracy_float = float(accuracy) | |
| except (ValueError, TypeError): | |
| pass | |
| if accuracy_float is None: | |
| accuracy_float = 0.58 | |
| return { | |
| "trained_date": trained_date, | |
| "features": features, | |
| "accuracy": accuracy_float, | |
| "model_type": "tech", | |
| "status": "success" | |
| } | |
| except Exception as e: | |
| # Return error information if metadata retrieval fails | |
| return { | |
| "trained_date": None, | |
| "features": [], | |
| "accuracy": None, | |
| "model_type": "tech", | |
| "status": "error", | |
| "error_message": str(e) | |
| } |