Spaces:
Sleeping
Sleeping
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| import pandas as pd | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.pipeline import make_pipeline | |
| import pytesseract | |
| from PIL import Image | |
| import requests | |
| from io import BytesIO | |
| import base64 | |
| import re | |
| import datetime | |
| import os | |
| script_dir = os.path.dirname(os.path.abspath(__file__)) | |
| dist_dir = os.path.abspath(os.path.join(script_dir, '../dist')) | |
| if os.path.exists(dist_dir): | |
| print(f"📦 Serving static files from production build: {dist_dir}") | |
| app = Flask(__name__, static_folder=dist_dir, static_url_path='/') | |
| else: | |
| print("🧪 Running in development API mode (no dist folder found)") | |
| app = Flask(__name__) | |
| # Enable CORS for all API paths | |
| CORS(app, resources={r"/api/*": {"origins": "*"}}) | |
| print("🧠 Loading NLP model from dataset.csv...") | |
| try: | |
| script_dir = os.path.dirname(os.path.abspath(__file__)) | |
| dataset_path = os.path.join(script_dir, 'dataset.csv') | |
| df = pd.read_csv(dataset_path) | |
| df = df.dropna(subset=['text', 'Pattern Category']) | |
| model = make_pipeline( | |
| TfidfVectorizer(ngram_range=(1, 2)), | |
| LogisticRegression(C=10.0, class_weight='balanced', max_iter=1000) | |
| ) | |
| model.fit(df['text'], df['Pattern Category']) | |
| print("✅ AI Model ready and trained!") | |
| except Exception as e: | |
| print(f"❌ Error loading dataset: {e}") | |
| print("📉 Loading Financial Distress model from Financial Distress.csv...") | |
| distress_model = None | |
| try: | |
| distress_path = os.path.join(script_dir, 'Financial Distress.csv') | |
| distress_df = pd.read_csv(distress_path) | |
| # Financial Distress target value <= -0.5 is distress (class 1), else healthy (class 0) | |
| features_cols = [f'x{i}' for i in range(1, 84)] | |
| distress_df = distress_df.dropna(subset=['Financial Distress'] + features_cols) | |
| X_distress = distress_df[features_cols] | |
| y_distress = (distress_df['Financial Distress'] <= -0.5).astype(int) | |
| distress_model = LogisticRegression(max_iter=1000) | |
| distress_model.fit(X_distress, y_distress) | |
| print("✅ Financial Distress Model ready and trained!") | |
| except Exception as e: | |
| print(f"❌ Error loading Financial Distress dataset: {e}") | |
| print("📰 Loading Reddit News from RedditNews.csv...") | |
| news_list = [] | |
| try: | |
| news_path = os.path.join(script_dir, 'RedditNews.csv') | |
| news_df = pd.read_csv(news_path) | |
| news_df = news_df.dropna(subset=['News']) | |
| news_list = news_df.to_dict(orient='records') | |
| print(f"✅ Loaded {len(news_list)} news headlines successfully!") | |
| except Exception as e: | |
| print(f"❌ Error loading RedditNews dataset: {e}") | |
| def analyze_headline_sentiment(news_text): | |
| pos_words = ["gain", "rise", "success", "profit", "win", "high", "positive", "growth", "launch", "heal", "benefit", "good", "strong", "advance", "recover", "save", "safe"] | |
| neg_words = ["fail", "drop", "loss", "crash", "investigate", "lawsuit", "down", "recession", "decrease", "kill", "death", "protest", "strike", "bad", "weak", "decline", "default", "scandal", "abuse", "murder", "hurt", "risk"] | |
| text_lower = news_text.lower() | |
| pos_score = sum(1 for word in pos_words if word in text_lower) | |
| neg_score = sum(1 for word in neg_words if word in text_lower) | |
| if pos_score > neg_score: | |
| return "positive" | |
| elif neg_score > pos_score: | |
| return "negative" | |
| else: | |
| return "neutral" | |
| def get_stock_news(symbol): | |
| symbol = symbol.upper() | |
| keywords = { | |
| "AAPL": ["apple", "iphone", "macbook", "ipad", "jobs", "tech"], | |
| "NVDA": ["chip", "nvidia", "gpu", "ai", "intel", "amd", "tech"], | |
| "TSLA": ["tesla", "elon", "musk", "electric", "battery", "car"], | |
| "COIN": ["bitcoin", "crypto", "blockchain", "exchange", "coinbase", "sec"], | |
| "MSFT": ["microsoft", "windows", "azure", "cloud", "tech", "gates"], | |
| "GOOGL": ["google", "alphabet", "search", "youtube", "android", "tech"], | |
| } | |
| stock_kws = keywords.get(symbol, [symbol.lower(), "market", "economy", "finance", "stocks", "trade", "shares"]) | |
| matching = [] | |
| for item in news_list: | |
| news_text = str(item['News']) | |
| text_lower = news_text.lower() | |
| if any(kw in text_lower for kw in stock_kws): | |
| matching.append(item) | |
| if len(matching) >= 100: | |
| break | |
| if len(matching) < 5: | |
| general_kws = ["market", "economy", "finance", "stocks", "trade", "shares"] | |
| for item in news_list: | |
| news_text = str(item['News']) | |
| text_lower = news_text.lower() | |
| if any(kw in text_lower for kw in general_kws): | |
| matching.append(item) | |
| if len(matching) >= 100: | |
| break | |
| formatted_news = [] | |
| positive_count = 0 | |
| negative_count = 0 | |
| # We want a mix of headlines (e.g. 6 headlines) | |
| selected_items = matching[:6] | |
| if len(selected_items) < 6: | |
| selected_items = news_list[:6] | |
| for item in selected_items: | |
| headline = str(item['News']) | |
| date = str(item['Date']) | |
| sentiment = analyze_headline_sentiment(headline) | |
| if sentiment == "positive": | |
| positive_count += 1 | |
| elif sentiment == "negative": | |
| negative_count += 1 | |
| formatted_news.append({ | |
| "headline": headline, | |
| "date": date, | |
| "sentiment": sentiment | |
| }) | |
| total_val = positive_count + negative_count | |
| if total_val > 0: | |
| sentiment_pct = round((positive_count / total_val) * 100) | |
| else: | |
| # A deterministic fallback sentiment based on symbol hash | |
| hash_val = sum(ord(c) for c in symbol) | |
| sentiment_pct = 40 + (hash_val % 30) # 40% to 70% positive | |
| return { | |
| "articles": formatted_news, | |
| "sentimentPercent": sentiment_pct | |
| } | |
| def get_distress_risk(symbol): | |
| if distress_model is None: | |
| return {"riskLevel": "Low", "distressProbability": 15.0, "rawDistressScore": 0.05, "isDistressed": False} | |
| symbol = symbol.upper() | |
| try: | |
| script_dir = os.path.dirname(os.path.abspath(__file__)) | |
| distress_path = os.path.join(script_dir, 'Financial Distress.csv') | |
| distress_df = pd.read_csv(distress_path) | |
| # Filter rows to select distressed vs healthy for demo consistency | |
| distressed_rows = distress_df[distress_df['Financial Distress'] <= -0.5] | |
| healthy_rows = distress_df[distress_df['Financial Distress'] > 0.5] | |
| if len(distressed_rows) == 0 or len(healthy_rows) == 0: | |
| return {"riskLevel": "Low", "distressProbability": 10.0, "rawDistressScore": 0.1, "isDistressed": False} | |
| # Deterministic row selection based on symbol hash | |
| hash_val = sum(ord(c) for c in symbol) | |
| # Override specific symbols for demonstration purposes: | |
| if symbol == 'COIN': | |
| # Map COIN to a distressed row | |
| row = distressed_rows.iloc[hash_val % len(distressed_rows)] | |
| elif symbol in ['AAPL', 'NVDA', 'MSFT', 'GOOGL']: | |
| # Map healthy tech to healthy row | |
| row = healthy_rows.iloc[hash_val % len(healthy_rows)] | |
| else: | |
| # Map deterministically from entire dataset | |
| row = distress_df.iloc[hash_val % len(distress_df)] | |
| features_cols = [f'x{i}' for i in range(1, 84)] | |
| features = row[features_cols].values.reshape(1, -1) | |
| prob = distress_model.predict_proba(features)[0][1] # probability of class 1 (distress) | |
| is_distressed = bool(distress_model.predict(features)[0] == 1) | |
| # Define risk levels: | |
| if prob > 0.6 or is_distressed: | |
| risk_level = "High" | |
| elif prob > 0.25: | |
| risk_level = "Medium" | |
| else: | |
| risk_level = "Low" | |
| raw_score = float(row['Financial Distress']) | |
| return { | |
| "riskLevel": risk_level, | |
| "distressProbability": round(float(prob) * 100, 1), | |
| "rawDistressScore": round(raw_score, 3), | |
| "isDistressed": is_distressed | |
| } | |
| except Exception as e: | |
| print(f"Error evaluating distress risk for {symbol}: {e}") | |
| return {"riskLevel": "Low", "distressProbability": 15.0, "rawDistressScore": 0.1, "isDistressed": False} | |
| def get_severity(prediction): | |
| severity_map = { | |
| "Urgency": "high", | |
| "Scarcity": "medium", | |
| "Social Proof": "low", | |
| "Misdirection": "high", | |
| "Obstruction": "critical", | |
| "Sneaking": "critical", | |
| "Forced Action": "critical" | |
| } | |
| return severity_map.get(prediction, "medium") | |
| def get_compliance_metadata(prediction, text): | |
| if prediction == "Urgency": | |
| violation = "Urgency tactics create artificial pressure to force immediate transaction decisions, potentially violating 12 CFR 1041 prohibiting deceptive acts or practices." | |
| recommendation = f"Remove countdown timers or false urgency text like '{text}'." | |
| elif prediction == "Scarcity": | |
| violation = "Scarcity tactics (e.g. artificial stock limits) manipulate consumers into immediate purchases, violating FTC Act Section 5 against deceptive practices." | |
| recommendation = f"Ensure the statement '{text}' is backed by real-time inventory systems. If not verified, remove it." | |
| elif prediction == "Social Proof": | |
| violation = "Unverified social proof notifications (e.g. 'X bought this recently') can mislead consumers, violating general rules on deceptive advertisements." | |
| recommendation = f"Validate that '{text}' is based on genuine user activity. Otherwise, disable this alert." | |
| elif prediction == "Misdirection": | |
| violation = "Misdirection visual/language design (like confirmshaming) steers users away from their intended choices, violating consumer choice principles." | |
| recommendation = f"Change the option text in '{text}' to use clear and neutral language (e.g. 'Cancel' / 'Confirm') without guilt-tripping." | |
| elif prediction == "Obstruction": | |
| violation = "Obstruction (making cancellation or opt-out complex) violates EFTA and CFPB guidelines against hard-to-cancel billing structures." | |
| recommendation = f"Simplify subscription cancellation related to '{text}'. The exit path should be as simple as the sign-up path." | |
| elif prediction == "Sneaking": | |
| violation = "Sneaking (adding hidden costs or pre-selected add-ons) violates EFTA and deceptive practices rules by charging without active consent." | |
| recommendation = f"Ensure '{text}' does not lead to pre-checked options. Require explicit opt-in for all additional items or services." | |
| elif prediction == "Forced Action": | |
| violation = "Forced Action requires consumers to perform unrelated actions (e.g. consent to tracking) to finish a task, violating consumer choice guidelines." | |
| recommendation = f"Allow users to proceed past '{text}' without mandatory signups or sharing non-essential data." | |
| else: | |
| violation = "General deceptive pattern detected that may violate CFPB guidelines against deceptive acts or practices." | |
| recommendation = "Redesign copy and flow to maximize user transparency and choice." | |
| return violation, recommendation | |
| def analyze_image(): | |
| if request.method == 'OPTIONS': | |
| return jsonify({}), 200 | |
| data = request.json | |
| image_url = data.get('imageUrl', '') | |
| print(f"\n📸 Received request for image analysis...") | |
| try: | |
| # Load image (handling both base64 Data URLs and HTTP URLs) | |
| if image_url.startswith('data:image/'): | |
| pattern = re.compile(r'^data:image/\w+;base64,(.*)$') | |
| match = pattern.match(image_url) | |
| if not match: | |
| raise ValueError("Invalid data URL format") | |
| img_data = base64.b64decode(match.group(1)) | |
| img = Image.open(BytesIO(img_data)) | |
| else: | |
| response = requests.get(image_url, timeout=10) | |
| img = Image.open(BytesIO(response.content)) | |
| img_width, img_height = img.size | |
| print(f"👁️ Image size: {img_width}x{img_height}. Scanning for text blocks...") | |
| # Get OCR data (bounding box coordinates) | |
| ocr_data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT) | |
| extracted_text = pytesseract.image_to_string(img).strip() | |
| # Group words by block and line number to reconstruct coherent lines | |
| lines = {} | |
| n_boxes = len(ocr_data['text']) | |
| for i in range(n_boxes): | |
| text = ocr_data['text'][i].strip() | |
| if not text: | |
| continue | |
| block_num = ocr_data['block_num'][i] | |
| line_num = ocr_data['line_num'][i] | |
| key = (block_num, line_num) | |
| left = ocr_data['left'][i] | |
| top = ocr_data['top'][i] | |
| width = ocr_data['width'][i] | |
| height = ocr_data['height'][i] | |
| if key not in lines: | |
| lines[key] = { | |
| 'words': [], | |
| 'left': left, | |
| 'top': top, | |
| 'right': left + width, | |
| 'bottom': top + height | |
| } | |
| lines[key]['words'].append(text) | |
| lines[key]['left'] = min(lines[key]['left'], left) | |
| lines[key]['top'] = min(lines[key]['top'], top) | |
| lines[key]['right'] = max(lines[key]['right'], left + width) | |
| lines[key]['bottom'] = max(lines[key]['bottom'], top + height) | |
| dark_patterns = [] | |
| pattern_id = 1 | |
| for key, info in lines.items(): | |
| line_text = " ".join(info['words']).strip() | |
| if len(line_text) < 3: | |
| continue | |
| # Predict pattern class | |
| prediction = model.predict([line_text])[0] | |
| if prediction != "Not Dark Pattern": | |
| probs = model.predict_proba([line_text])[0] | |
| classes = model.classes_ | |
| pred_idx = list(classes).index(prediction) | |
| confidence_score = round(probs[pred_idx] * 100) | |
| severity = get_severity(prediction) | |
| violation, recommendation = get_compliance_metadata(prediction, line_text) | |
| # Convert coords to percentages relative to image size | |
| left_pct = round((info['left'] / img_width) * 100, 2) | |
| top_pct = round((info['top'] / img_height) * 100, 2) | |
| width_pct = round(((info['right'] - info['left']) / img_width) * 100, 2) | |
| height_pct = round(((info['bottom'] - info['top']) / img_height) * 100, 2) | |
| dark_patterns.append({ | |
| "id": str(pattern_id), | |
| "type": prediction, | |
| "severity": severity, | |
| "description": f"Deceptive copywriting matching {prediction} pattern.", | |
| "confidence": confidence_score, | |
| "location": { | |
| "x": left_pct, | |
| "y": top_pct, | |
| "width": width_pct, | |
| "height": height_pct | |
| }, | |
| "cfpbViolation": violation, | |
| "recommendation": recommendation | |
| }) | |
| pattern_id += 1 | |
| # Calculate trust score & compliance report | |
| if not dark_patterns: | |
| overall_score = 98 | |
| risk_level = "low" | |
| compliance_report = { | |
| "cfpbAlignment": 98, | |
| "issues": [], | |
| "recommendations": [] | |
| } | |
| else: | |
| deductions = { | |
| "critical": 25, | |
| "high": 15, | |
| "medium": 10, | |
| "low": 5 | |
| } | |
| score_deduction = sum(deductions.get(p["severity"], 10) for p in dark_patterns) | |
| overall_score = max(5, 100 - score_deduction) | |
| if overall_score >= 80: | |
| risk_level = "low" | |
| elif overall_score >= 60: | |
| risk_level = "medium" | |
| elif overall_score >= 45: | |
| risk_level = "high" | |
| else: | |
| risk_level = "critical" | |
| issues = list(dict.fromkeys([p["cfpbViolation"] for p in dark_patterns])) | |
| recommendations = list(dict.fromkeys([p["recommendation"] for p in dark_patterns])) | |
| compliance_report = { | |
| "cfpbAlignment": overall_score, | |
| "issues": issues, | |
| "recommendations": recommendations | |
| } | |
| return jsonify({ | |
| "imageUrl": image_url, | |
| "extractedText": extracted_text or "No text detected in screenshot.", | |
| "overallScore": overall_score, | |
| "riskLevel": risk_level, | |
| "darkPatterns": dark_patterns, | |
| "complianceReport": compliance_report, | |
| "timestamp": datetime.datetime.now().isoformat() | |
| }) | |
| except Exception as e: | |
| print(f"❌ Analysis failed: {e}") | |
| return jsonify({"error": f"Failed to process image: {str(e)}"}), 500 | |
| def get_dataset(): | |
| query = request.args.get('q', '').strip() | |
| category = request.args.get('category', '').strip() | |
| limit = int(request.args.get('limit', 50)) | |
| offset = int(request.args.get('offset', 0)) | |
| try: | |
| filtered_df = df | |
| if query: | |
| filtered_df = filtered_df[filtered_df['text'].str.contains(query, case=False, na=False)] | |
| if category: | |
| filtered_df = filtered_df[filtered_df['Pattern Category'].str.lower() == category.lower()] | |
| total = len(filtered_df) | |
| sliced_df = filtered_df.iloc[offset:offset+limit] | |
| records = sliced_df.to_dict(orient='records') | |
| # Get category counts for stats | |
| counts = df['Pattern Category'].value_counts().to_dict() | |
| return jsonify({ | |
| "status": "success", | |
| "total": total, | |
| "limit": limit, | |
| "offset": offset, | |
| "records": records, | |
| "categoryCounts": counts | |
| }) | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def get_stock_data(symbol): | |
| try: | |
| url = f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol.upper()}?range=1d&interval=5m" | |
| headers = { | |
| 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' | |
| } | |
| response = requests.get(url, headers=headers, timeout=10) | |
| # If Yahoo Finance rate-limits (429) or fails, fallback to generating simulated stock metrics deterministically | |
| if response.status_code != 200: | |
| print(f"⚠️ Yahoo Finance API returned status {response.status_code} for {symbol}. Generating simulated fallback.") | |
| import random | |
| hash_val = sum(ord(c) for c in symbol.upper()) | |
| base_price = 50.0 + (hash_val % 450) | |
| history = [] | |
| price = base_price | |
| for i in range(20): | |
| price = price * (1 + (random.random() * 0.04 - 0.02)) | |
| history.append({ | |
| "time": f"T-{20-i}m", | |
| "price": round(price, 2) | |
| }) | |
| current_price = price | |
| price_change = price * 0.015 | |
| price_change_pct = 1.5 | |
| distress_info = get_distress_risk(symbol) | |
| news_info = get_stock_news(symbol) | |
| return jsonify({ | |
| "status": "success", | |
| "symbol": symbol.upper(), | |
| "price": round(current_price, 2), | |
| "change": round(price_change, 2), | |
| "changePercent": round(price_change_pct, 2), | |
| "history": history, | |
| "distress": distress_info, | |
| "news": news_info, | |
| "simulated": True | |
| }) | |
| data = response.json() | |
| if not data.get('chart') or not data['chart'].get('result'): | |
| return jsonify({"status": "error", "message": "Invalid stock symbol or no data available."}), 404 | |
| result = data['chart']['result'][0] | |
| meta = result.get('meta', {}) | |
| current_price = meta.get('regularMarketPrice', 0) | |
| previous_close = meta.get('chartPreviousClose', current_price) | |
| price_change = current_price - previous_close | |
| price_change_pct = (price_change / previous_close) * 100 if previous_close else 0 | |
| timestamps = result.get('timestamp', []) | |
| quotes = result.get('indicators', {}).get('quote', [{}])[0].get('close', []) | |
| history = [] | |
| for t, val in zip(timestamps, quotes): | |
| if val is not None: | |
| time_str = datetime.datetime.fromtimestamp(t).strftime('%H:%M') | |
| history.append({ | |
| "time": time_str, | |
| "price": round(val, 2) | |
| }) | |
| distress_info = get_distress_risk(symbol) | |
| news_info = get_stock_news(symbol) | |
| return jsonify({ | |
| "status": "success", | |
| "symbol": symbol.upper(), | |
| "price": round(current_price, 2), | |
| "change": round(price_change, 2), | |
| "changePercent": round(price_change_pct, 2), | |
| "history": history, | |
| "distress": distress_info, | |
| "news": news_info | |
| }) | |
| except Exception as e: | |
| # Fallback if any internal python exception occurs | |
| print(f"⚠️ Exception in get_stock_data for {symbol}: {e}. Generating simulated fallback.") | |
| import random | |
| hash_val = sum(ord(c) for c in symbol.upper()) | |
| base_price = 50.0 + (hash_val % 450) | |
| history = [] | |
| price = base_price | |
| for i in range(20): | |
| price = price * (1 + (random.random() * 0.04 - 0.02)) | |
| history.append({ | |
| "time": f"T-{20-i}m", | |
| "price": round(price, 2) | |
| }) | |
| return jsonify({ | |
| "status": "success", | |
| "symbol": symbol.upper(), | |
| "price": round(price, 2), | |
| "change": round(price * 0.015, 2), | |
| "changePercent": 1.5, | |
| "history": history, | |
| "distress": get_distress_risk(symbol), | |
| "news": get_stock_news(symbol), | |
| "simulated": True | |
| }) | |
| def format_volume(val): | |
| try: | |
| val_float = float(val) | |
| if val_float >= 1e9: | |
| return f"${val_float / 1e9:.2f} B" | |
| elif val_float >= 1e6: | |
| return f"${val_float / 1e6:.2f} M" | |
| else: | |
| return f"${val_float:,.0f}" | |
| except Exception: | |
| return "$0.00" | |
| def get_market_assets(): | |
| print("📈 Fetching live market assets statistics...") | |
| assets_def = [ | |
| {"symbol": "BTC-USD", "name": "Bitcoin", "type": "crypto", "basePrice": 67645.0, "baseChange": 1.4}, | |
| {"symbol": "ETH-USD", "name": "Ethereum", "type": "crypto", "basePrice": 3450.0, "baseChange": -0.8}, | |
| {"symbol": "SOL-USD", "name": "Solana", "type": "crypto", "basePrice": 165.20, "baseChange": 4.2}, | |
| {"symbol": "DOGE-USD", "name": "Dogecoin", "type": "crypto", "basePrice": 0.142, "baseChange": -2.1}, | |
| {"symbol": "NVDA", "name": "NVIDIA Corp.", "type": "stock", "basePrice": 120.50, "baseChange": 3.8}, | |
| {"symbol": "AAPL", "name": "Apple Inc.", "type": "stock", "basePrice": 175.20, "baseChange": -0.4}, | |
| {"symbol": "TSLA", "name": "Tesla Inc.", "type": "stock", "basePrice": 185.0, "baseChange": 0.5}, | |
| {"symbol": "COIN", "name": "Coinbase Global", "type": "stock", "basePrice": 220.40, "baseChange": -1.9} | |
| ] | |
| headers = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' | |
| } | |
| output_assets = [] | |
| for asset in assets_def: | |
| symbol = asset["symbol"] | |
| base_price = asset["basePrice"] | |
| base_change = asset["baseChange"] | |
| price = base_price | |
| change_pct = base_change | |
| high_24h = base_price * 1.02 | |
| low_24h = base_price * 0.98 | |
| volume_val = 0.0 | |
| sparkline = [] | |
| is_simulated = True | |
| # 1. Try Yahoo Finance Chart API | |
| try: | |
| url = f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}?range=1d&interval=15m" | |
| res = requests.get(url, headers=headers, timeout=4) | |
| if res.status_code == 200: | |
| data = res.json() | |
| result = data['chart']['result'][0] | |
| meta = result.get('meta', {}) | |
| current_price = meta.get('regularMarketPrice') | |
| previous_close = meta.get('chartPreviousClose') | |
| if current_price is not None and current_price > 0: | |
| price = current_price | |
| if previous_close is not None and previous_close > 0: | |
| change_pct = ((current_price - previous_close) / previous_close) * 100 | |
| high_24h = meta.get('regularMarketDayHigh', price * 1.02) | |
| low_24h = meta.get('regularMarketDayLow', price * 0.98) | |
| vol = meta.get('regularMarketVolume', 0) | |
| if asset["type"] == "stock": | |
| # Stock volume is shares; multiply by price to get USD volume | |
| volume_val = vol * price | |
| else: | |
| volume_val = vol | |
| # Extract historical quotes for sparkline | |
| quotes = result.get('indicators', {}).get('quote', [{}])[0].get('close', []) | |
| clean_quotes = [round(val, 2 if price >= 1.0 else 4) for val in quotes if val is not None] | |
| if len(clean_quotes) >= 10: | |
| step = len(clean_quotes) / 10.0 | |
| sparkline = [clean_quotes[int(i * step)] for i in range(10)] | |
| sparkline[-1] = clean_quotes[-1] | |
| elif len(clean_quotes) > 0: | |
| sparkline = clean_quotes | |
| is_simulated = False | |
| except Exception as e: | |
| print(f"⚠️ Yahoo Finance failed for {symbol}: {e}") | |
| # 2. Try Binance API as fallback for Crypto | |
| if is_simulated and asset["type"] == "crypto": | |
| try: | |
| binance_sym = symbol.replace("-USD", "USDT") | |
| url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={binance_sym}" | |
| res = requests.get(url, timeout=3) | |
| if res.status_code == 200: | |
| b_data = res.json() | |
| price = float(b_data["lastPrice"]) | |
| change_pct = float(b_data["priceChangePercent"]) | |
| high_24h = float(b_data["highPrice"]) | |
| low_24h = float(b_data["lowPrice"]) | |
| volume_val = float(b_data["quoteVolume"]) # quoteVolume is USDT volume | |
| is_simulated = False | |
| print(f"✅ Fallback to Binance successful for {symbol}: Price = {price}") | |
| except Exception as e: | |
| print(f"⚠️ Binance fallback failed for {symbol}: {e}") | |
| # 3. Fallback to Simulated Quote if all APIs failed | |
| import random | |
| if is_simulated: | |
| price = price * (1 + (random.random() * 0.002 - 0.001)) | |
| high_24h = price * 1.02 | |
| low_24h = price * 0.98 | |
| # Use deterministic base volume | |
| if symbol == "BTC-USD": volume_val = 28450210000 | |
| elif symbol == "ETH-USD": volume_val = 14120450000 | |
| elif symbol == "SOL-USD": volume_val = 3510800000 | |
| elif symbol == "DOGE-USD": volume_val = 1240150000 | |
| elif symbol == "NVDA": volume_val = 18540900000 | |
| elif symbol == "AAPL": volume_val = 8450600000 | |
| elif symbol == "TSLA": volume_val = 9210300000 | |
| else: volume_val = 2150400000 | |
| # Ensure we have a valid 10-point sparkline | |
| if not sparkline or len(sparkline) < 10: | |
| sparkline = [] | |
| hist_price = price * (1 - (change_pct / 100)) | |
| for i in range(10): | |
| jitter = (random.random() * 0.02 - 0.01) * hist_price | |
| sparkline.append(round(hist_price + (i * (price - hist_price)/9) + jitter, 2 if price >= 1.0 else 4)) | |
| # Format volume string | |
| if volume_val > 0: | |
| volume_str = format_volume(volume_val) | |
| else: | |
| if symbol == "BTC-USD": volume_str = "$28,450,210,000" | |
| elif symbol == "ETH-USD": volume_str = "$14,120,450,000" | |
| elif symbol == "SOL-USD": volume_str = "$3,510,800,000" | |
| elif symbol == "DOGE-USD": volume_str = "$1,240,150,000" | |
| elif symbol == "NVDA": volume_str = "$18,540,900,000" | |
| elif symbol == "AAPL": volume_str = "$8,450,600,000" | |
| elif symbol == "TSLA": volume_str = "$9,210,300,000" | |
| else: volume_str = "$2,150,400,000" | |
| # Calculate dynamic setups | |
| price_str = f"{price:,.2f}" if price >= 1.0 else f"{price:,.4f}" | |
| if change_pct >= 2.0: | |
| buy_p = random.randint(65, 80) | |
| sell_p = 100 - buy_p | |
| momentum = "Bullish" | |
| signal = "Strong Buy" | |
| analysis = f"{asset['name']} is experiencing a powerful breakout, surging {change_pct:.2f}% to ${price_str}. High volume buying pressure ({buy_p}%) has overwhelmed key overhead resistance. Relative Strength Index (RSI) is expanding rapidly, confirming strong bullish momentum." | |
| elif change_pct >= 0.2: | |
| buy_p = random.randint(52, 64) | |
| sell_p = 100 - buy_p | |
| momentum = "Bullish" | |
| signal = "Buy" | |
| analysis = f"{asset['name']} maintains a positive structure, trading up {change_pct:.2f}% at ${price_str}. The asset is holding support above the 50-day moving average, with spot order book flow showing steady bid accumulation." | |
| elif change_pct <= -2.0: | |
| buy_p = random.randint(20, 38) | |
| sell_p = 100 - buy_p | |
| momentum = "Bearish" | |
| signal = "Sell" | |
| analysis = f"{asset['name']} has broken key support to the downside, dropping {change_pct:.2f}% to ${price_str}. Sellers are in full control with {sell_p}% volume pressure. Momentum indicators are oversold, but advise waiting for a bottom structure to form." | |
| elif change_pct <= -0.2: | |
| buy_p = random.randint(39, 47) | |
| sell_p = 100 - buy_p | |
| momentum = "Bearish" | |
| signal = "Sell" | |
| analysis = f"{asset['name']} is under minor distribution, trading down {change_pct:.2f}% at ${price_str}. Selling pressure is slightly elevated, suggesting continuation of a short-term consolidation pattern before buyers re-engage." | |
| else: | |
| buy_p = random.randint(48, 51) | |
| sell_p = 100 - buy_p | |
| momentum = "Neutral" | |
| signal = "Hold" | |
| analysis = f"{asset['name']} is moving in a tight sideways range, currently priced at ${price_str} ({change_pct:+.2f}%). Spot volume is balanced, indicating a neutral tug-of-war between bulls and bears with no clear trend direction." | |
| # Assign warning metadata (theme based) | |
| if symbol == "BTC-USD": | |
| risk_lvl = "Low" | |
| warnings = ["Urgency FOMO banners active on major brokers", "Stealth spread markups active on buy trades"] | |
| elif symbol == "ETH-USD": | |
| risk_lvl = "Low" | |
| warnings = ["Deceptive staking yield advertisements (hidden locking fees)"] | |
| elif symbol == "SOL-USD": | |
| risk_lvl = "Low" | |
| warnings = ["High transaction failure gas fee warnings omitted by UI"] | |
| elif symbol == "DOGE-USD": | |
| risk_lvl = "Medium" | |
| warnings = ["Pressure pop-ups ('DOGE is spiking! Buy before it runs!') active"] | |
| elif symbol == "NVDA": | |
| risk_lvl = "Low" | |
| warnings = ["Visual misdirection: hiding index correlation parameters"] | |
| elif symbol == "AAPL": | |
| risk_lvl = "Low" | |
| warnings = ["Sneaked add-on fees (recurring equity analyst newsletter pre-checked)"] | |
| elif symbol == "TSLA": | |
| risk_lvl = "Low" | |
| warnings = ["Deceptive countdown timers on pricing locked deals"] | |
| else: # COIN | |
| risk_lvl = "High" | |
| warnings = ["Deceptive rating: suppressing distress warning under low risk badge", "Cart sneaking: $4.99 options analytics pre-checked"] | |
| output_assets.append({ | |
| "symbol": symbol, | |
| "name": asset["name"], | |
| "type": asset["type"], | |
| "price": round(price, 2 if price >= 1.0 else 4), | |
| "change24h": round(change_pct, 2), | |
| "volume24h": volume_str, | |
| "high24h": round(high_24h, 2 if price >= 1.0 else 4), | |
| "low24h": round(low_24h, 2 if price >= 1.0 else 4), | |
| "sparkline": sparkline, | |
| "buySellPattern": { | |
| "buyPressure": buy_p, | |
| "sellPressure": sell_p, | |
| "momentum": momentum, | |
| "signal": signal, | |
| "analysis": analysis | |
| }, | |
| "fintechWarnings": { | |
| "riskLevel": risk_lvl, | |
| "activePatterns": warnings | |
| } | |
| }) | |
| return jsonify({ | |
| "status": "success", | |
| "assets": output_assets, | |
| "timestamp": datetime.datetime.now().isoformat() | |
| }) | |
| def analyze_options(): | |
| if request.method == 'OPTIONS': | |
| return jsonify({}), 200 | |
| data = request.json | |
| image_url = data.get('imageUrl', '') | |
| print(f"\n📊 Received request for options analysis...") | |
| extracted_text = "" | |
| is_options_screenshot = False | |
| try: | |
| if image_url: | |
| # Decode base64 | |
| if image_url.startswith('data:image/'): | |
| pattern = re.compile(r'^data:image/\w+;base64,(.*)$') | |
| match = pattern.match(image_url) | |
| if not match: | |
| raise ValueError("Invalid data URL format") | |
| img_data = base64.b64decode(match.group(1)) | |
| img = Image.open(BytesIO(img_data)) | |
| else: | |
| response = requests.get(image_url, timeout=10) | |
| img = Image.open(BytesIO(response.content)) | |
| extracted_text = pytesseract.image_to_string(img).strip() | |
| # Simple check if this is an options chain screenshot | |
| lower_text = extracted_text.lower() | |
| keywords = ["deribit", "option", "strike", "call", "put", "iv bid", "iv ask", "delta", "bid-ask"] | |
| keyword_matches = sum(1 for kw in keywords if kw in lower_text) | |
| if keyword_matches >= 2 or any(str(strike) in lower_text for strike in [65000, 66000, 67000, 68000, 69000, 70000]): | |
| is_options_screenshot = True | |
| except Exception as e: | |
| print(f"⚠️ OCR extraction failed: {e}. Falling back to default options analysis.") | |
| is_options_screenshot = False | |
| # Default/simulated option chain values based on BTC at $67,645.00 | |
| # Perfect copy of Deribit screenshot data | |
| spot_price = 67645.00 | |
| expiry_date = "03 Jun 2026" | |
| time_to_expiry_hours = 16.7 | |
| # We will generate a structured grid for strikes: 65,000 to 75,000 | |
| strikes_data = [ | |
| {"strike": 65000, "callSize": 2.0, "callBid": 0.0375, "callAsk": 0.0460, "callIvBid": 69.0, "callIvAsk": 122.2, "putSize": 10.8, "putBid": 0.0011, "putAsk": 0.0013, "putIvBid": 66.3, "putIvAsk": 69.2}, | |
| {"strike": 66000, "callSize": 2.2, "callBid": 0.0235, "callAsk": 0.0315, "callIvBid": 62.5, "callIvAsk": 96.3, "putSize": 25.2, "putBid": 0.0024, "putAsk": 0.0028, "putIvBid": 59.8, "putIvAsk": 63.3}, | |
| {"strike": 67000, "callSize": 0.1, "callBid": 0.0145, "callAsk": 0.0155, "callIvBid": 51.5, "callIvAsk": 57.7, "putSize": 79.6, "putBid": 0.0050, "putAsk": 0.0060, "putIvBid": 51.6, "putIvAsk": 57.9}, | |
| {"strike": 68000, "callSize": 13.2, "callBid": 0.0060, "callAsk": 0.0070, "callIvBid": 47.8, "callIvAsk": 53.7, "putSize": 0.4, "putBid": 0.0115, "putAsk": 0.0120, "putIvBid": 49.3, "putIvAsk": 52.3}, | |
| {"strike": 69000, "callSize": 0.4, "callBid": 0.0018, "callAsk": 0.0021, "callIvBid": 46.6, "callIvAsk": 49.3, "putSize": 5.5, "putBid": 0.0150, "putAsk": 0.0180, "putIvBid": 39.4, "putIvAsk": 59.8}, | |
| {"strike": 70000, "callSize": 3.5, "callBid": 0.0009, "callAsk": 0.0011, "callIvBid": 46.8, "callIvAsk": 49.4, "putSize": 0.8, "putBid": 0.0270, "putAsk": 0.0300, "putIvBid": 31.5, "putIvAsk": 64.6}, | |
| {"strike": 71000, "callSize": 2.7, "callBid": 0.0002, "callAsk": 0.0003, "callIvBid": 55.0, "callIvAsk": 58.7, "putSize": 0.4, "putBid": 0.0485, "putAsk": 0.0515, "putIvBid": 50.0, "putIvAsk": 87.3}, | |
| {"strike": 72000, "callSize": 10.2, "callBid": 0.0001, "callAsk": 0.0002, "callIvBid": 55.9, "callIvAsk": 61.6, "putSize": 0.7, "putBid": 0.0630, "putAsk": 0.0660, "putIvBid": 50.0, "putIvAsk": 101.0} | |
| ] | |
| # Calculate Put-Call Ratio (PCR) and ATM Skew | |
| # ATM strike is 68000 (closest to spot $67,645.00) | |
| atm_strike = 68000 | |
| atm_opt = next((x for x in strikes_data if x["strike"] == atm_strike), strikes_data[3]) | |
| atm_call_iv = (atm_opt["callIvBid"] + atm_opt["callIvAsk"]) / 2 | |
| atm_put_iv = (atm_opt["putIvBid"] + atm_opt["putIvAsk"]) / 2 | |
| iv_skew = round(atm_put_iv - atm_call_iv, 2) # positive skew means Puts are more expensive than Calls (bearish fear) | |
| total_call_size = sum(x["callSize"] for x in strikes_data) | |
| total_put_size = sum(x["putSize"] for x in strikes_data) | |
| pcr_ratio = round(total_put_size / total_call_size, 2) if total_call_size > 0 else 1.0 | |
| # Determine "When is a good time to buy and sell options" | |
| signals = [] | |
| recommended_action = "Hold" | |
| action_explanation = "" | |
| if iv_skew > 1.5: | |
| signals.append(f"Volatility Skew is highly positive (+{iv_skew}%), showing put option premiums are heavily inflated due to downside hedging demand (market fear).") | |
| if pcr_ratio > 1.1: | |
| recommended_action = "Sell Put Credit Spreads / Buy Calls" | |
| action_explanation = "Fear is peaking (high IV skew + high Put-Call Ratio). This is historically a good time to SELL puts to collect high option premiums, or BUY call options at a discount as the underlying asset consolidates near support." | |
| else: | |
| recommended_action = "Sell Put Options (Income Harvest)" | |
| action_explanation = "Put premiums are elevated. Sell put options or put spreads to harvest high volatility premium." | |
| elif iv_skew < -1.5: | |
| signals.append(f"Volatility Skew is negative ({iv_skew}%), showing call option premiums are inflated due to upside FOMO buying.") | |
| if pcr_ratio < 0.8: | |
| recommended_action = "Buy Put Options (Hedge) / Sell Calls" | |
| action_explanation = "Market euphoria is high. Call premiums are overpriced and Put options are cheap. It is a good time to BUY puts as a low-cost downside hedge or SELL covered calls to lock in yield." | |
| else: | |
| recommended_action = "Buy Puts / Sell Call Spreads" | |
| action_explanation = "Call premiums are inflated. Buy cheap puts to position for a reversion." | |
| else: | |
| signals.append(f"Volatility Skew is neutral ({iv_skew}%), indicating balanced demand between call and put options.") | |
| if pcr_ratio > 1.3: | |
| recommended_action = "Buy Calls (Contrarian)" | |
| action_explanation = "Put-Call ratio is heavily skewed to puts, indicating oversold sentiment. A good time to buy calls for a relief rally." | |
| elif pcr_ratio < 0.6: | |
| recommended_action = "Buy Puts (Contrarian)" | |
| action_explanation = "Put-Call ratio is heavily skewed to calls, indicating overbought hype. A good time to buy puts for a cooling off period." | |
| else: | |
| recommended_action = "Hold / Neutral" | |
| action_explanation = "Volatility and volume distributions are balanced. Standard market conditions. Avoid opening large directional options exposure; look for range-bound credit strategies." | |
| # Identify dark patterns/compliance issues in the options layout | |
| compliance_issues = [] | |
| compliance_recommendations = [] | |
| # 1. Hidden option markups (wide spreads) | |
| wide_spreads = False | |
| for x in strikes_data: | |
| call_mid = (x["callBid"] + x["callAsk"]) / 2 | |
| call_spread_pct = ((x["callAsk"] - x["callBid"]) / call_mid) * 100 if call_mid > 0 else 0 | |
| if call_spread_pct > 15: | |
| wide_spreads = True | |
| break | |
| if wide_spreads or is_options_screenshot: | |
| compliance_issues.append("Stealth Option Markups: Bid-ask spreads on out-of-the-money options exceed 15% of the option's value, acting as a hidden fee (Sneaking).") | |
| compliance_recommendations.append("Disclose the bid-ask spread percentages in real-time next to the order button so retail traders understand the slippage fee.") | |
| # 2. Urgency | |
| compliance_issues.append("Urgency Expiry Alerts: Countdown banner 'BTC-3JUN26 contracts expire in 16 hours! Lock in premium now!' creates artificial pressure (Urgency).") | |
| compliance_recommendations.append("Remove high-pressure countdown phrases like 'Lock in premium now' and replace with a standard, non-colored expiry date label.") | |
| # 3. Complexity barrier | |
| compliance_issues.append("Obstruction of Key Information: Displaying Greek metrics (Delta, Gamma, Vega, Theta) and IV levels without tooltips or explanations confuses retail users into making risky leverage trades (Obstruction).") | |
| compliance_recommendations.append("Add interactive tooltips explaining what Delta, IV, and Bid/Ask spreads mean, along with a warning of the high risk of options trading.") | |
| overall_score = 65 | |
| risk_level = "medium" | |
| return jsonify({ | |
| "status": "success", | |
| "asset": "BTC", | |
| "spotPrice": spot_price, | |
| "expiryDate": expiry_date, | |
| "timeToExpiryHours": time_to_expiry_hours, | |
| "strikes": strikes_data, | |
| "ivSkew": iv_skew, | |
| "putCallRatio": pcr_ratio, | |
| "signal": { | |
| "recommendation": recommended_action, | |
| "explanation": action_explanation, | |
| "indicators": signals | |
| }, | |
| "compliance": { | |
| "score": overall_score, | |
| "riskLevel": risk_level, | |
| "issues": compliance_issues, | |
| "recommendations": compliance_recommendations | |
| }, | |
| "extractedText": extracted_text or "Simulated options chain screen text parsed." | |
| }) | |
| if os.path.exists(dist_dir): | |
| def serve(path): | |
| if path != "" and os.path.exists(os.path.join(app.static_folder, path)): | |
| return app.send_static_file(path) | |
| else: | |
| return app.send_static_file('index.html') | |
| if __name__ == '__main__': | |
| app.run(host='0.0.0.0', port=8000, debug=True) |