SPB Git

spb/vquant Public MIT

VibeQuant — AI-powered institutional-grade financial intelligence platform.

TypeScript 84.3% Python 11.7% JavaScript 1.6% CSS 1.5% HTML 0.7%
15.5 KB · 388 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3#  VibeQuant (vquant) — AI-Powered Financial Intelligence Platform4# -----------------------------------------------------------------------------5#  File:      server/services/plotService.py6#7#  Author:    Simon-Pierre Boucher8#  Contact:   contact@spboucher.ai9#  Website:   https://www.spboucher.ai10#  Demo:      https://www.vquant.ai11#  License:   MIT (see LICENSE)12#13#  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.14# =============================================================================1516"""17Plot Service18Generates customizable plots and charts for financial data visualization19"""20import json21import sys22import numpy as np23import pandas as pd24import matplotlib25matplotlib.use('Agg')  # Non-interactive backend for server-side rendering26import matplotlib.pyplot as plt27import matplotlib.ticker as ticker28import matplotlib.dates as mdates29import seaborn as sns30from io import BytesIO31import base6432from datetime import datetime33import os3435# Import our FMP client for data fetching36sys.path.insert(0, os.path.dirname(__file__))37from fmpClient import get_historical_prices3839# Set style40sns.set_style("whitegrid")41plt.rcParams['figure.facecolor'] = 'white'42plt.rcParams['axes.facecolor'] = 'white'434445def create_plot(data_json: str) -> dict:46    """47    Create customizable plots for data visualization4849    Args:50        data_json: JSON string containing:51            - plot_type: Type of plot ('line', 'bar', 'scatter', 'histogram', 'candlestick', 'area', 'pie', 'heatmap')52            - data: Plot data - can be:53                * dict with x and y keys (for simple plots)54                * dict with multiple series (for multi-line plots)55                * symbol (string) to auto-fetch from FMP API56            - title: Plot title (optional)57            - xlabel: X-axis label (optional)58            - ylabel: Y-axis label (optional)59            - color: Color or list of colors (optional, defaults: blue, green, red, etc.)60            - figsize: Figure size as [width, height] (optional, default: [12, 6])61            - grid: Show grid (optional, default: True)62            - legend: Show legend (optional, default: True if multiple series)63            - style: Line style for line plots (optional: '-', '--', '-.', ':')64            - marker: Marker style for scatter/line plots (optional: 'o', 's', '^', etc.)65            - alpha: Transparency (optional, 0-1, default: 0.8)66            - theme: Color theme ('default', 'dark', 'colorful') (optional)6768    Returns:69        Dictionary containing plot as base64-encoded PNG image70    """71    try:72        data = json.loads(data_json)7374        plot_type = data.get('plot_type', 'line').lower()75        plot_data = data.get('data')76        title = data.get('title', 'Financial Chart')77        xlabel = data.get('xlabel', '')78        ylabel = data.get('ylabel', '')79        color = data.get('color', None)80        figsize = data.get('figsize', [12, 6])81        show_grid = data.get('grid', True)82        show_legend = data.get('legend', None)83        style = data.get('style', '-')84        marker = data.get('marker', None)85        alpha = data.get('alpha', 0.8)86        theme = data.get('theme', 'default')8788        # Handle symbol-based data fetching89        if isinstance(plot_data, str):90            symbol = plot_data91            print(f"Fetching historical data for {symbol}...", file=sys.stderr)92            hist_data = get_historical_prices(symbol)93            historical = hist_data['historical']9495            # Convert to format suitable for plotting96            dates = [h['date'] for h in reversed(historical)]97            closes = [h['close'] for h in reversed(historical)]9899            # Convert date strings to datetime objects for better formatting100            dates_parsed = [datetime.strptime(d, '%Y-%m-%d') if isinstance(d, str) else d for d in dates]101102            # Only include close price by default (volume would dwarf price on same scale)103            plot_data = {104                'x': dates_parsed,105                'y': closes106            }107108            if not title or title == 'Financial Chart':109                title = f'{symbol} Stock Price'110            if not ylabel:111                ylabel = 'Price ($)'112            if not xlabel:113                xlabel = 'Date'114115        # Apply theme116        if theme == 'dark':117            plt.style.use('dark_background')118        elif theme == 'colorful':119            sns.set_palette("husl")120121        # Create figure122        fig, ax = plt.subplots(figsize=tuple(figsize))123124        # Parse data format125        if isinstance(plot_data, dict):126            # Check if it's simple x/y format or multi-series127            if 'x' in plot_data and 'y' in plot_data:128                # Simple x/y plot129                x_data = plot_data['x']130                y_data = plot_data['y']131                series_data = {'data': {'x': x_data, 'y': y_data}}132            elif 'x' in plot_data:133                # Multi-series with shared x-axis134                x_data = plot_data['x']135                series_data = {}136                for key, values in plot_data.items():137                    if key != 'x':138                        series_data[key] = {'x': x_data, 'y': values}139            else:140                return {141                    "success": False,142                    "error": "Invalid data format. Must include 'x' key."143                }144        elif isinstance(plot_data, list):145            # List of values, create auto x-axis146            x_data = list(range(len(plot_data)))147            y_data = plot_data148            series_data = {'data': {'x': x_data, 'y': y_data}}149        else:150            return {151                "success": False,152                "error": "Data must be a dict or list"153            }154155        # Color palette156        if color is None:157            colors = ['#2563eb', '#16a34a', '#dc2626', '#9333ea', '#ea580c', '#0891b2']158        elif isinstance(color, str):159            colors = [color]160        else:161            colors = color162163        # Generate plot based on type164        if plot_type == 'line':165            for idx, (label, data_dict) in enumerate(series_data.items()):166                x = data_dict['x']167                y = data_dict['y']168                plot_color = colors[idx % len(colors)]169                ax.plot(x, y, label=label, color=plot_color, linewidth=2,170                       linestyle=style, marker=marker, alpha=alpha)171172        elif plot_type == 'bar':173            if len(series_data) == 1:174                # Single series bar chart175                label, data_dict = list(series_data.items())[0]176                x = data_dict['x']177                y = data_dict['y']178                ax.bar(x, y, color=colors[0], alpha=alpha, label=label)179            else:180                # Multiple series bar chart181                x = list(series_data.values())[0]['x']182                width = 0.8 / len(series_data)183                for idx, (label, data_dict) in enumerate(series_data.items()):184                    y = data_dict['y']185                    offset = width * idx - (width * len(series_data) / 2)186                    x_pos = np.arange(len(x)) + offset187                    ax.bar(x_pos, y, width, label=label, color=colors[idx % len(colors)], alpha=alpha)188                ax.set_xticks(np.arange(len(x)))189                ax.set_xticklabels(x, rotation=45, ha='right')190191        elif plot_type == 'scatter':192            for idx, (label, data_dict) in enumerate(series_data.items()):193                x = data_dict['x']194                y = data_dict['y']195                plot_color = colors[idx % len(colors)]196                marker_style = marker if marker else 'o'197                ax.scatter(x, y, label=label, color=plot_color, alpha=alpha,198                          s=100, marker=marker_style)199200        elif plot_type == 'area':201            for idx, (label, data_dict) in enumerate(series_data.items()):202                x = data_dict['x']203                y = data_dict['y']204                plot_color = colors[idx % len(colors)]205                ax.fill_between(x, y, alpha=alpha*0.6, color=plot_color, label=label)206                ax.plot(x, y, color=plot_color, linewidth=2, alpha=alpha)207208        elif plot_type == 'histogram':209            # Histogram only uses y values210            for idx, (label, data_dict) in enumerate(series_data.items()):211                y = data_dict['y']212                plot_color = colors[idx % len(colors)]213                ax.hist(y, bins=30, alpha=alpha*0.7, color=plot_color,214                       edgecolor='black', label=label)215216        elif plot_type == 'pie':217            # Pie chart uses first series only218            label, data_dict = list(series_data.items())[0]219            labels = data_dict['x']220            values = data_dict['y']221            ax.pie(values, labels=labels, autopct='%1.1f%%', startangle=90,222                  colors=colors[:len(values)], textprops={'fontsize': 10})223            ax.axis('equal')224225        elif plot_type == 'candlestick':226            # Candlestick requires OHLC data227            if 'open' in plot_data and 'high' in plot_data and 'low' in plot_data and 'close' in plot_data:228                dates = plot_data.get('x', list(range(len(plot_data['open']))))229                opens = plot_data['open']230                highs = plot_data['high']231                lows = plot_data['low']232                closes = plot_data['close']233234                # Convert dates to numbers if they're strings235                if isinstance(dates[0], str):236                    x_pos = np.arange(len(dates))237                else:238                    x_pos = dates239240                # Plot candlesticks241                for i, (o, h, l, c, x) in enumerate(zip(opens, highs, lows, closes, x_pos)):242                    color_candle = colors[0] if c >= o else colors[2]  # green if up, red if down243                    # High-low line244                    ax.plot([x, x], [l, h], color=color_candle, linewidth=1, alpha=alpha)245                    # Open-close rectangle246                    height = abs(c - o)247                    bottom = min(o, c)248                    ax.add_patch(plt.Rectangle((x - 0.3, bottom), 0.6, height,249                                              facecolor=color_candle, alpha=alpha))250251                # Set x-axis labels if dates are strings252                if isinstance(dates[0], str):253                    # Show every nth label to avoid crowding254                    step = max(1, len(dates) // 10)255                    ax.set_xticks(x_pos[::step])256                    ax.set_xticklabels([dates[i] for i in range(0, len(dates), step)],257                                      rotation=45, ha='right')258            else:259                return {260                    "success": False,261                    "error": "Candlestick plot requires 'open', 'high', 'low', 'close' data"262                }263264        elif plot_type == 'heatmap':265            # Heatmap requires 2D data266            if isinstance(plot_data, dict) and 'values' in plot_data:267                values = np.array(plot_data['values'])268                xlabels = plot_data.get('xlabels', None)269                ylabels = plot_data.get('ylabels', None)270271                im = ax.imshow(values, cmap='RdYlGn', aspect='auto', alpha=alpha)272273                if xlabels:274                    ax.set_xticks(np.arange(len(xlabels)))275                    ax.set_xticklabels(xlabels, rotation=45, ha='right')276                if ylabels:277                    ax.set_yticks(np.arange(len(ylabels)))278                    ax.set_yticklabels(ylabels)279280                plt.colorbar(im, ax=ax)281            else:282                return {283                    "success": False,284                    "error": "Heatmap requires 'values' as 2D array in data"285                }286287        else:288            return {289                "success": False,290                "error": f"Unknown plot type: {plot_type}. Supported types: line, bar, scatter, histogram, area, pie, candlestick, heatmap"291            }292293        # Customize plot294        ax.set_title(title, fontsize=16, fontweight='bold', pad=20)295        if xlabel:296            ax.set_xlabel(xlabel, fontsize=12)297        if ylabel:298            ax.set_ylabel(ylabel, fontsize=12)299300        if show_grid and plot_type not in ['pie', 'heatmap']:301            ax.grid(True, alpha=0.3, linestyle='--')302303        # Show legend if multiple series or explicitly requested304        if show_legend is None:305            show_legend = len(series_data) > 1 or plot_type == 'pie'306307        if show_legend and plot_type not in ['pie', 'heatmap', 'candlestick']:308            ax.legend(loc='best', framealpha=0.9, fontsize=10)309310        # Format axes for better readability311        if plot_type in ['line', 'area', 'scatter', 'bar']:312            x_values = list(series_data.values())[0]['x']313314            # Check if x-axis contains datetime objects315            if len(x_values) > 0 and isinstance(x_values[0], datetime):316                # Use matplotlib date formatting317                ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))318                ax.xaxis.set_major_locator(mdates.AutoDateLocator())319                plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')320            elif len(x_values) > 0 and isinstance(x_values[0], str):321                # Show every nth label to avoid crowding for string labels322                step = max(1, len(x_values) // 15)323                ax.set_xticks(range(0, len(x_values), step))324                ax.set_xticklabels([x_values[i] for i in range(0, len(x_values), step)],325                                  rotation=45, ha='right')326327            # Format y-axis with thousand separators for financial data328            if ylabel and ('$' in ylabel or 'Price' in ylabel or 'Revenue' in ylabel or 'Value' in ylabel):329                ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'${x:,.0f}' if abs(x) >= 1 else f'${x:.2f}'))330            elif ylabel and '%' in ylabel:331                ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x:.1f}%'))332            else:333                # General number formatting with thousand separators334                ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, p: f'{x:,.0f}' if abs(x) >= 1 else f'{x:.2f}'))335336        plt.tight_layout()337338        # Generate unique filename with timestamp and random UUID339        import uuid340        import time341        timestamp = int(time.time() * 1000)342        unique_id = str(uuid.uuid4())[:8]343        filename = f"plot_{timestamp}_{unique_id}.png"344345        # Get the project root directory (2 levels up from this file)346        project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))347        plots_dir = os.path.join(project_root, 'public', 'plots')348349        # Create directory if it doesn't exist350        os.makedirs(plots_dir, exist_ok=True)351352        # Save the plot to file353        filepath = os.path.join(plots_dir, filename)354        plt.savefig(filepath, format='png', dpi=100, bbox_inches='tight',355                   facecolor='white', edgecolor='none')356        plt.close(fig)357358        # Return the relative URL path359        image_url = f"/plots/{filename}"360361        return {362            "success": True,363            "plot_type": plot_type,364            "image_url": image_url,365            "format": "png",366            "title": title367        }368369    except Exception as e:370        import traceback371        error_details = traceback.format_exc()372        print(f"ERROR: {error_details}", file=sys.stderr)373        return {374            "success": False,375            "error": str(e)376        }377378379if __name__ == "__main__":380    # Read input from stdin381    input_data = sys.stdin.read()382383    # Create plot384    result = create_plot(input_data)385386    # Output result as JSON387    print(json.dumps(result))388