from datetime import timedelta import json from django.contrib.auth import authenticate, login from django.contrib.auth.models import User from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError as DjangoValidationError from django.utils import timezone import httpx from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response from .models import Screener, ScreenerSymbolCache from apps.copilot.services import run_chat_completion @api_view(["GET"]) def health_check(request): return Response( { "status": "ok", "service": "fpna-copilot-api", } ) @api_view(["POST"]) def login_user(request): username = str(request.data.get("username", "")).strip() password = str(request.data.get("password", "")) if not username or not password: return Response( {"detail": "Username and password are required."}, status=status.HTTP_400_BAD_REQUEST, ) user = authenticate(request, username=username, password=password) if not user: return Response( {"detail": "Invalid username or password."}, status=status.HTTP_401_UNAUTHORIZED, ) login(request, user) return Response( { "status": "ok", "message": "Login successful.", "user": {"id": user.id, "username": user.username, "email": user.email}, } ) @api_view(["POST"]) def register_user(request): username = str(request.data.get("username", "")).strip() password = str(request.data.get("password", "")) confirm_password = str(request.data.get("confirmPassword", "")) email = str(request.data.get("email", "")).strip() if not username or not password: return Response( {"detail": "Username and password are required."}, status=status.HTTP_400_BAD_REQUEST, ) if confirm_password and password != confirm_password: return Response( {"detail": "Passwords do not match."}, status=status.HTTP_400_BAD_REQUEST, ) if User.objects.filter(username=username).exists(): return Response( {"detail": "Username is already taken."}, status=status.HTTP_400_BAD_REQUEST, ) if email and User.objects.filter(email=email).exists(): return Response( {"detail": "Email is already in use."}, status=status.HTTP_400_BAD_REQUEST, ) try: validate_password(password) except DjangoValidationError as exc: return Response({"detail": " ".join(exc.messages)}, status=status.HTTP_400_BAD_REQUEST) user = User.objects.create_user( username=username, password=password, email=email, ) login(request, user) return Response( { "status": "ok", "message": "Registration successful.", "user": {"id": user.id, "username": user.username, "email": user.email}, }, status=status.HTTP_201_CREATED, ) def _serialize_screener(screener): return { "id": screener.id, "userId": screener.user_id, "name": screener.name, "filters": screener.filters or {}, "columns": screener.columns or [], "selectedSymbols": screener.selected_symbols or [], "createdAt": screener.created_at.isoformat(), "updatedAt": screener.updated_at.isoformat(), } def _resolve_user(request): if request.user and request.user.is_authenticated: return request.user header_user_id = request.headers.get("X-User-Id") query_user_id = request.query_params.get("userId") body_user_id = request.data.get("userId") if isinstance(request.data, dict) else None user_id = header_user_id or query_user_id or body_user_id if not user_id: return None try: normalized_user_id = int(user_id) except (TypeError, ValueError): return None return User.objects.filter(id=normalized_user_id).first() def _require_user(request): user = _resolve_user(request) if user: return user, None return None, Response( {"detail": "Authentication required. Pass a valid session or X-User-Id header."}, status=status.HTTP_401_UNAUTHORIZED, ) @api_view(["GET", "POST"]) def screeners_collection(request): user, error_response = _require_user(request) if error_response: return error_response if request.method == "GET": screeners = Screener.objects.filter(user=user) return Response([_serialize_screener(screener) for screener in screeners]) name = str(request.data.get("name", "")).strip() if not name: return Response({"detail": "Screener name is required."}, status=status.HTTP_400_BAD_REQUEST) filters = request.data.get("filters", {}) columns = request.data.get("columns", []) selected_symbols = request.data.get("selectedSymbols", []) if filters is None: filters = {} if columns is None: columns = [] if not isinstance(filters, dict): return Response({"detail": "'filters' must be a JSON object."}, status=status.HTTP_400_BAD_REQUEST) if not isinstance(columns, list): return Response({"detail": "'columns' must be a JSON array."}, status=status.HTTP_400_BAD_REQUEST) if not isinstance(selected_symbols, list): return Response({"detail": "'selectedSymbols' must be a JSON array."}, status=status.HTTP_400_BAD_REQUEST) normalized_selected_symbols = [] for value in selected_symbols: symbol = str(value).strip().upper() if symbol and symbol not in normalized_selected_symbols: normalized_selected_symbols.append(symbol) screener = Screener.objects.create( user=user, name=name, filters=filters, columns=columns, selected_symbols=normalized_selected_symbols, ) return Response(_serialize_screener(screener), status=status.HTTP_201_CREATED) @api_view(["GET", "PUT", "DELETE"]) def screener_detail(request, screener_id): user, error_response = _require_user(request) if error_response: return error_response screener = Screener.objects.filter(user=user, id=screener_id).first() if not screener: return Response({"detail": "Screener not found."}, status=status.HTTP_404_NOT_FOUND) if request.method == "GET": return Response(_serialize_screener(screener)) if request.method == "DELETE": screener.delete() return Response(status=status.HTTP_204_NO_CONTENT) name = request.data.get("name") filters = request.data.get("filters") columns = request.data.get("columns") selected_symbols = request.data.get("selectedSymbols") if name is not None: normalized_name = str(name).strip() if not normalized_name: return Response({"detail": "Screener name cannot be empty."}, status=status.HTTP_400_BAD_REQUEST) screener.name = normalized_name if filters is not None: if not isinstance(filters, dict): return Response({"detail": "'filters' must be a JSON object."}, status=status.HTTP_400_BAD_REQUEST) screener.filters = filters if columns is not None: if not isinstance(columns, list): return Response({"detail": "'columns' must be a JSON array."}, status=status.HTTP_400_BAD_REQUEST) screener.columns = columns if selected_symbols is not None: if not isinstance(selected_symbols, list): return Response({"detail": "'selectedSymbols' must be a JSON array."}, status=status.HTTP_400_BAD_REQUEST) normalized_selected_symbols = [] for value in selected_symbols: symbol = str(value).strip().upper() if symbol and symbol not in normalized_selected_symbols: normalized_selected_symbols.append(symbol) screener.selected_symbols = normalized_selected_symbols if filters is not None or selected_symbols is not None: screener.cached_results = None screener.last_run_at = None screener.save() return Response(_serialize_screener(screener)) def _fetch_fmp_json(endpoint, params=None): raise NotImplementedError("Legacy REST helper has been replaced by MCP helper.") class FmpMcpError(Exception): pass class FmpMcpAccessError(FmpMcpError): pass FMP_DEFAULT_SECTORS = [ "Technology", "Financial Services", "Healthcare", "Consumer Cyclical", "Communication Services", "Industrials", "Consumer Defensive", "Energy", "Utilities", "Real Estate", "Basic Materials", ] FMP_DEFAULT_COUNTRIES = [ "US", "CA", "GB", "DE", "FR", "JP", "IN", "AU", "SG", ] FMP_DEFAULT_EXCHANGES = [ "NASDAQ", "NYSE", "AMEX", "TSX", "LSE", "EURONEXT", ] TRADINGVIEW_SCAN_URL = "https://scanner.tradingview.com/america/scan" TRADINGVIEW_SCAN_PARAMS = {"label-product": "screener-stock"} TRADINGVIEW_SCAN_HEADERS = { "Accept": "application/json", "Accept-Language": "en-US,en;q=0.9", "Cache-Control": "no-cache", "Content-Type": "application/json;charset=UTF-8", "Origin": "https://in.tradingview.com", "Pragma": "no-cache", "Referer": "https://in.tradingview.com/", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/145.0.0.0 Safari/537.36" ), } TRADINGVIEW_COLUMNS = ["ticker-view","close","type","typespecs","pricescale","minmov","fractional","minmove2","currency","change","Perf.W","Perf.1M","Perf.3M","Perf.6M","Perf.YTD","Perf.Y","Perf.5Y","Perf.10Y","Perf.All","Volatility.W","Volatility.M","premarket_close","premarket_change","premarket_gap","premarket_volume","gap","volume","volume_change","postmarket_close","postmarket_change","postmarket_volume","market_cap_basic","fundamental_currency_code","Perf.1Y.MarketCap","price_earnings_ttm","price_earnings_growth_ttm","price_sales_current","price_book_fq","price_to_cash_f_operating_activities_ttm","price_free_cash_flow_ttm","price_to_cash_ratio","enterprise_value_current","enterprise_value_to_revenue_ttm","enterprise_value_to_ebit_ttm","enterprise_value_ebitda_ttm","dps_common_stock_prim_issue_fy","dps_common_stock_prim_issue_fq","dividends_yield_current","dividends_yield","dividend_payout_ratio_ttm","dps_common_stock_prim_issue_yoy_growth_fy","continuous_dividend_payout","continuous_dividend_growth","gross_margin_ttm","operating_margin_ttm","pre_tax_margin_ttm","net_margin_ttm","free_cash_flow_margin_ttm","return_on_assets_fq","return_on_equity_fq","return_on_invested_capital_fq","research_and_dev_ratio_ttm","sell_gen_admin_exp_other_ratio_ttm","fiscal_period_current","fiscal_period_end_current","total_revenue_ttm","total_revenue_yoy_growth_ttm","gross_profit_ttm","oper_income_ttm","net_income_ttm","ebitda_ttm","earnings_per_share_diluted_ttm","earnings_per_share_diluted_yoy_growth_ttm","total_assets_fq","total_current_assets_fq","cash_n_short_term_invest_fq","total_liabilities_fq","total_debt_fq","net_debt_fq","total_equity_fq","current_ratio_fq","quick_ratio_fq","debt_to_equity_fq","cash_n_short_term_invest_to_total_debt_fq","cash_f_operating_activities_ttm","cash_f_investing_activities_ttm","cash_f_financing_activities_ttm","free_cash_flow_ttm","neg_capital_expenditures_ttm","revenue_per_share_ttm","earnings_per_share_basic_ttm","operating_cash_flow_per_share_ttm","free_cash_flow_per_share_ttm","ebit_per_share_ttm","ebitda_per_share_ttm","book_value_per_share_fq","total_debt_per_share_fq","cash_per_share_fq","TechRating_1D","TechRating_1D.tr","MARating_1D","MARating_1D.tr","OsRating_1D","OsRating_1D.tr","RSI","Mom","AO","CCI20","Stoch.K","Stoch.D","Candle.3BlackCrows","Candle.3WhiteSoldiers","Candle.AbandonedBaby.Bearish","Candle.AbandonedBaby.Bullish","Candle.Doji","Candle.Doji.Dragonfly","Candle.Doji.Gravestone","Candle.Engulfing.Bearish","Candle.Engulfing.Bullish","Candle.EveningStar","Candle.Hammer","Candle.HangingMan","Candle.Harami.Bearish","Candle.Harami.Bullish","Candle.InvertedHammer","Candle.Kicking.Bearish","Candle.Kicking.Bullish","Candle.LongShadow.Lower","Candle.LongShadow.Upper","Candle.Marubozu.Black","Candle.Marubozu.White","Candle.MorningStar","Candle.ShootingStar","Candle.SpinningTop.Black","Candle.SpinningTop.White","Candle.TriStar.Bearish","Candle.TriStar.Bullish"] TRADINGVIEW_COLUMN_INDEX = {name: idx for idx, name in enumerate(TRADINGVIEW_COLUMNS)} TRADINGVIEW_DEFAULT_FILTER2 = { "operator": "and", "operands": [ { "operation": { "operator": "or", "operands": [ { "operation": { "operator": "and", "operands": [ {"expression": {"left": "type", "operation": "equal", "right": "stock"}}, {"expression": {"left": "typespecs", "operation": "has", "right": ["common"]}}, ], } }, { "operation": { "operator": "and", "operands": [ {"expression": {"left": "type", "operation": "equal", "right": "stock"}}, {"expression": {"left": "typespecs", "operation": "has", "right": ["preferred"]}}, ], } }, {"operation": {"operator": "and", "operands": [{"expression": {"left": "type", "operation": "equal", "right": "dr"}}]}}, { "operation": { "operator": "and", "operands": [ {"expression": {"left": "type", "operation": "equal", "right": "fund"}}, {"expression": {"left": "typespecs", "operation": "has_none_of", "right": ["etf"]}}, ], } }, ], } }, {"expression": {"left": "typespecs", "operation": "has_none_of", "right": ["pre-ipo"]}}, ], } SYMBOL_CACHE_TTL = timedelta(hours=1) class TradingViewError(Exception): pass AI_FIELD_MAP = { "symbol": "symbol", "company": "companyName", "company_name": "companyName", "name": "companyName", "price": "price", "volume": "volume", "market_cap": "marketCap", "marketcap": "marketCap", "pe": "pe", "p_e": "pe", "eps": "eps", "change": "changePercentage", "change_pct": "changePercentage", "change_percentage": "changePercentage", "dividend_yield": "dividendYieldTTM", "sector": "sector", "country": "country", "analyst_rating": "analystRating", } AI_ALLOWED_OPERATORS = {"=", "==", "!=", ">", ">=", "<", "<=", "contains", "not_contains"} def _extract_json_payload(raw_text): text = str(raw_text or "").strip() if not text: return None if text.startswith("```"): text = text.strip("`") if "\n" in text: text = text.split("\n", 1)[1] text = text.rsplit("```", 1)[0].strip() try: return json.loads(text) except json.JSONDecodeError: pass start = text.find("{") end = text.rfind("}") if start >= 0 and end > start: candidate = text[start : end + 1] try: return json.loads(candidate) except json.JSONDecodeError: return None return None def _parse_human_number(value): if isinstance(value, (int, float)): return float(value) raw = str(value or "").strip().replace(",", "") if not raw: return None multiplier = 1.0 suffix = raw[-1].lower() if suffix == "k": multiplier = 1e3 raw = raw[:-1] elif suffix == "m": multiplier = 1e6 raw = raw[:-1] elif suffix == "b": multiplier = 1e9 raw = raw[:-1] elif suffix == "t": multiplier = 1e12 raw = raw[:-1] raw = raw.replace("%", "") try: return float(raw) * multiplier except ValueError: return None def _normalize_ai_condition(raw_condition): if not isinstance(raw_condition, dict): return None field_raw = str(raw_condition.get("field", "")).strip().lower() operator = str(raw_condition.get("operator", "")).strip().lower() value = raw_condition.get("value") if not field_raw or operator not in AI_ALLOWED_OPERATORS: return None normalized_field = AI_FIELD_MAP.get(field_raw, field_raw) if normalized_field not in AI_FIELD_MAP.values(): return None return {"field": normalized_field, "operator": operator, "value": value} def _compare_value(actual, operator, expected): if operator in {"contains", "not_contains"}: haystack = str(actual or "").lower() needle = str(expected or "").lower() matched = needle in haystack return matched if operator == "contains" else not matched actual_num = _parse_human_number(actual) expected_num = _parse_human_number(expected) if actual_num is not None and expected_num is not None: if operator in {"=", "=="}: return actual_num == expected_num if operator == "!=": return actual_num != expected_num if operator == ">": return actual_num > expected_num if operator == ">=": return actual_num >= expected_num if operator == "<": return actual_num < expected_num if operator == "<=": return actual_num <= expected_num return False actual_str = str(actual or "").lower() expected_str = str(expected or "").lower() if operator in {"=", "=="}: return actual_str == expected_str if operator == "!=": return actual_str != expected_str if operator == ">": return actual_str > expected_str if operator == ">=": return actual_str >= expected_str if operator == "<": return actual_str < expected_str if operator == "<=": return actual_str <= expected_str return False def _row_matches_conditions(row_data, conditions, mode): checks = [] for cond in conditions: field = cond["field"] actual = row_data.get(field) if field != "symbol" else row_data.get("symbol") checks.append(_compare_value(actual, cond["operator"], cond["value"])) if not checks: return False if mode == "or": return any(checks) return all(checks) def _build_ai_conditions_from_prompt(user_prompt): prompt = ( "You are an API parser for stock screener filters.\n" "Convert the user prompt into JSON only, no markdown.\n" "Return strict object schema:\n" '{ "mode": "and" | "or", "conditions": [ {"field": string, "operator": string, "value": string|number} ] }\n' "Allowed fields: symbol, company_name, price, volume, market_cap, pe, eps, change_percentage, dividend_yield, sector, country, analyst_rating.\n" "Allowed operators: =, ==, !=, >, >=, <, <=, contains, not_contains.\n" f"User prompt: {user_prompt}" ) result = run_chat_completion(prompt) payload = _extract_json_payload(result.get("content", "")) if not isinstance(payload, dict): return [], "and" raw_conditions = payload.get("conditions", []) mode = str(payload.get("mode", "and")).strip().lower() mode = "or" if mode == "or" else "and" normalized = [] for c in raw_conditions if isinstance(raw_conditions, list) else []: nc = _normalize_ai_condition(c) if nc: normalized.append(nc) return normalized, mode def _parse_int(value, default, minimum=None, maximum=None): try: parsed = int(value) except (TypeError, ValueError): parsed = default if minimum is not None: parsed = max(minimum, parsed) if maximum is not None: parsed = min(maximum, parsed) return parsed def _resolve_range(request, default_limit=100, max_limit=200): raw_limit = request.query_params.get("limit") limit = _parse_int(raw_limit, default_limit, minimum=1, maximum=max_limit) page_size = _parse_int(request.query_params.get("pageSize"), limit, minimum=1, maximum=max_limit) limit = page_size page = _parse_int(request.query_params.get("page"), 1, minimum=1) offset = _parse_int(request.query_params.get("offset"), (page - 1) * limit, minimum=0) return offset, limit def _resolve_sort(request, default_sort_by="market_cap_basic", default_sort_order="desc"): sort_by = str(request.query_params.get("sortBy", default_sort_by)).strip() if sort_by not in TRADINGVIEW_COLUMN_INDEX: sort_by = default_sort_by sort_order = str(request.query_params.get("sortOrder", default_sort_order)).strip().lower() if sort_order not in {"asc", "desc"}: sort_order = default_sort_order return sort_by, sort_order def _build_tradingview_scan_payload(extra_filters, offset, limit, sort_by="market_cap_basic", sort_order="desc"): return { "columns": TRADINGVIEW_COLUMNS, "filter": extra_filters, "ignore_unknown_fields": False, "options": {"lang": "en"}, "range": [offset, offset + limit], "sort": {"sortBy": sort_by, "sortOrder": sort_order}, "symbols": {}, "markets": ["america"], "filter2": TRADINGVIEW_DEFAULT_FILTER2, } def _scan_tradingview(payload): try: response = httpx.post( TRADINGVIEW_SCAN_URL, params=TRADINGVIEW_SCAN_PARAMS, headers=TRADINGVIEW_SCAN_HEADERS, json=payload, timeout=30.0, ) response.raise_for_status() parsed = response.json() except (httpx.HTTPError, ValueError) as exc: raise TradingViewError(str(exc)) from exc if not isinstance(parsed, dict): raise TradingViewError("TradingView returned an invalid response payload.") return parsed def _extract_symbol(raw_identifier, row_info): if isinstance(row_info, dict): info_symbol = str(row_info.get("name", "")).strip().upper() if info_symbol: return info_symbol raw = str(raw_identifier or "").strip() if ":" in raw: return raw.rsplit(":", 1)[-1].strip().upper() return raw.upper() def _tv_value(row_values, field_name): if not isinstance(row_values, list): return None idx = TRADINGVIEW_COLUMN_INDEX.get(field_name) if idx is None or idx >= len(row_values): return None value = row_values[idx] if value == "None": return None return value def _normalize_tradingview_row(raw_row): if not isinstance(raw_row, dict): return None values = raw_row.get("d") or [] base_info = values[0] if values and isinstance(values[0], dict) else {} symbol = _extract_symbol(raw_row.get("s"), base_info) if not symbol: return None logo = base_info.get("logo") if isinstance(base_info.get("logo"), dict) else {} logoid = str(logo.get("logoid") or base_info.get("logoid") or "").strip() logo_url = f"https://s3-symbol-logo.tradingview.com/{logoid}.svg" if logoid else None return { "symbol": symbol, "companyName": str(base_info.get("description", "")).strip() or None, "logoId": logoid or None, "logoUrl": logo_url, "price": _tv_value(values, "close"), "changePercentage": _tv_value(values, "change"), "volume": _tv_value(values, "volume"), "relativeVolume": None, "marketCap": _tv_value(values, "market_cap_basic"), "pe": _tv_value(values, "price_earnings_ttm"), "eps": _tv_value(values, "earnings_per_share_diluted_ttm"), "epsGrowthTTM": _tv_value(values, "earnings_per_share_diluted_yoy_growth_ttm"), "dividendYieldTTM": _tv_value(values, "dividends_yield_current"), "sector": None, "analystRating": _tv_value(values, "OsRating_1D.tr"), "country": None, "exchange": str(base_info.get("exchange", "")).strip(), "exchangeFullName": str(base_info.get("exchange", "")).strip(), "currency": _tv_value(values, "currency"), } def _first_row(payload): if isinstance(payload, list) and payload: first = payload[0] return first if isinstance(first, dict) else None if isinstance(payload, dict): return payload return None def _build_rows_from_selected_symbols(symbols): rows = [] for raw_symbol in symbols: row = _get_symbol_row_cached(str(raw_symbol).strip().upper()) if row: rows.append(row) return rows def _merge_symbol_rows(existing_row, new_row): merged = dict(existing_row or {}) for key, value in (new_row or {}).items(): if value not in (None, "", []): merged[key] = value return merged def _write_symbol_cache(symbol, row): normalized_symbol = str(symbol).strip().upper() if not normalized_symbol: return cache = ScreenerSymbolCache.objects.filter(symbol=normalized_symbol).first() now = timezone.now() if cache: cache.data = _merge_symbol_rows(cache.data or {}, row or {}) cache.last_fetched_at = now cache.save(update_fields=["data", "last_fetched_at", "updated_at"]) return ScreenerSymbolCache.objects.create( symbol=normalized_symbol, data=dict(row or {}), last_fetched_at=now, ) def _is_cache_fresh(cache_row): if not cache_row or not cache_row.last_fetched_at: return False return timezone.now() - cache_row.last_fetched_at <= SYMBOL_CACHE_TTL def _fetch_symbol_live_row(symbol): payload = _build_tradingview_scan_payload( extra_filters=[ {"left": "ticker-view-filter", "operation": "match", "right": symbol}, {"left": "is_primary", "operation": "equal", "right": True}, ], offset=0, limit=25, sort_by="market_cap_basic", sort_order="desc", ) response_payload = _scan_tradingview(payload) for raw_row in response_payload.get("data") or []: normalized = _normalize_tradingview_row(raw_row) if normalized and normalized.get("symbol") == symbol: return normalized # TradingView can still return fuzzy symbol matches, so use the first if an exact symbol is not available. data = response_payload.get("data") or [] if data: fallback_row = _normalize_tradingview_row(data[0]) if fallback_row: return fallback_row return {"symbol": symbol} def _get_symbol_row_cached(symbol): normalized_symbol = str(symbol).strip().upper() if not normalized_symbol: return None cache = ScreenerSymbolCache.objects.filter(symbol=normalized_symbol).first() if cache and _is_cache_fresh(cache): return _merge_symbol_rows({"symbol": normalized_symbol}, cache.data or {}) try: live_row = _fetch_symbol_live_row(normalized_symbol) except TradingViewError: live_row = None if live_row and any(value not in (None, "", []) for key, value in live_row.items() if key != "symbol"): merged_live = _merge_symbol_rows(cache.data if cache else {}, live_row) _write_symbol_cache(normalized_symbol, merged_live) return merged_live if cache and cache.data: # Network or provider limitation fallback: return stale cache. return _merge_symbol_rows({"symbol": normalized_symbol}, cache.data) return {"symbol": normalized_symbol} @api_view(["GET"]) def screener_filter_options(request): _, error_response = _require_user(request) if error_response: return error_response return Response( { "sectors": FMP_DEFAULT_SECTORS, "industries": [], "countries": FMP_DEFAULT_COUNTRIES, "exchanges": FMP_DEFAULT_EXCHANGES, "warnings": [], } ) @api_view(["GET"]) def run_screener(request, screener_id): user, error_response = _require_user(request) if error_response: return error_response screener = Screener.objects.filter(user=user, id=screener_id).first() if not screener: return Response({"detail": "Screener not found."}, status=status.HTTP_404_NOT_FOUND) selected_symbols = screener.selected_symbols or [] if selected_symbols: symbols_upper = [str(s).strip().upper() for s in selected_symbols if str(s).strip()] cache_rows = ScreenerSymbolCache.objects.filter(symbol__in=symbols_upper) rows = [] for cache_row in cache_rows: d = dict(cache_row.data or {}) d["symbol"] = cache_row.symbol rows.append(d) return Response( { "screener": _serialize_screener(screener), "results": rows, "cached": True, "pagination": { "offset": 0, "limit": len(rows), "returned": len(rows), "totalCount": len(rows), "sortBy": "symbol", "sortOrder": "asc", }, } ) return Response( { "screener": _serialize_screener(screener), "results": [], "cached": True, "pagination": {"offset": 0, "limit": 0, "returned": 0, "totalCount": 0, "sortBy": "symbol", "sortOrder": "asc"}, } ) @api_view(["POST"]) def ai_generate_screener_symbols(request, screener_id): user, error_response = _require_user(request) if error_response: return error_response screener = Screener.objects.filter(user=user, id=screener_id).first() if not screener: return Response({"detail": "Screener not found."}, status=status.HTTP_404_NOT_FOUND) user_prompt = str(request.data.get("prompt", "")).strip() if not user_prompt: return Response({"detail": "Request must include non-empty 'prompt'."}, status=status.HTTP_400_BAD_REQUEST) try: conditions, mode = _build_ai_conditions_from_prompt(user_prompt) except Exception as exc: return Response( {"detail": "Failed to parse screener prompt with LLM.", "error": str(exc)}, status=status.HTTP_502_BAD_GATEWAY, ) if not conditions: return Response( { "detail": "Could not derive valid filters from prompt. Try explicit fields like '@market_cap > 50M and @sector contains tech'." }, status=status.HTTP_400_BAD_REQUEST, ) matched_symbols = [] for cache_row in ScreenerSymbolCache.objects.all().iterator(): row_data = dict(cache_row.data or {}) row_data["symbol"] = cache_row.symbol if _row_matches_conditions(row_data, conditions, mode): matched_symbols.append(cache_row.symbol) existing = [str(s).strip().upper() for s in (screener.selected_symbols or []) if str(s).strip()] merged = [] seen = set() for symbol in existing + matched_symbols: s = str(symbol).strip().upper() if s and s not in seen: seen.add(s) merged.append(s) added_count = len([s for s in merged if s not in set(existing)]) screener.selected_symbols = merged screener.cached_results = None screener.last_run_at = None screener.save(update_fields=["selected_symbols", "cached_results", "last_run_at", "updated_at"]) return Response( { "status": "ok", "matchedCount": len(matched_symbols), "addedCount": added_count, "selectedSymbols": merged, "conditions": conditions, "mode": mode, } ) @api_view(["GET"]) def stock_symbol_search(request): from django.db.models import Q _, error_response = _require_user(request) if error_response: return error_response query = str(request.query_params.get("query", "")).strip() if len(query) < 1: return Response({"results": []}) limit = _parse_int(request.query_params.get("limit"), 10, minimum=1, maximum=25) offset = _parse_int(request.query_params.get("offset"), 0, minimum=0) q_filter = Q(symbol__icontains=query.upper()) | Q( data__companyName__icontains=query ) qs = ( ScreenerSymbolCache.objects.filter(q_filter) .order_by("symbol")[offset : offset + limit] ) results = [] for cache_row in qs: d = cache_row.data or {} symbol = cache_row.symbol results.append( { "symbol": symbol, "name": d.get("companyName") or "", "logoId": d.get("logoId"), "logoUrl": d.get("logoUrl"), "exchange": d.get("exchange") or "", "exchangeFullName": d.get("exchangeFullName") or "", "currency": d.get("currency") or "", "price": d.get("price"), "changePercentage": d.get("changePercentage"), "marketCap": d.get("marketCap"), "pe": d.get("pe"), } ) total = ScreenerSymbolCache.objects.filter(q_filter).count() return Response( { "results": results, "pagination": { "offset": offset, "limit": limit, "returned": len(results), "totalCount": total, "sortBy": "symbol", "sortOrder": "asc", }, } )