# -*- coding: utf-8 -*-
"""
Stock Vision Predictor - Main Web Application
Gradio Interface that combines OCR chart reading, live market data ingestion,
signal classification, 14-step trajectory forecasting, and backtest analytics.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import gradio as gr
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
try:
import spaces
except ImportError:
# Safe fallback for local Windows execution where 'spaces' may not be installed.
# GPU() must accept any args/kwargs (e.g. duration=120) and return the function unchanged.
class spaces:
@staticmethod
def GPU(*args, **kwargs):
# Called as @spaces.GPU(duration=120) โ returns a no-op decorator
# Called as @spaces.GPU โ args[0] is the function itself
def decorator(func):
return func
if len(args) == 1 and callable(args[0]) and not kwargs:
return args[0] # bare @spaces.GPU usage
return decorator # @spaces.GPU(...) usage
from src.backtester.accuracy import AccuracyEvaluator
from src.backtester.metrics import StrategyBacktester
from src.config import DEFAULT_TICKER, DEFAULT_TIMEFRAME, FALLBACK_TICKERS
from src.data.fetcher import MarketDataFetcher
from src.data.indicators import TechnicalIndicatorEngine
from src.models.forecaster import PriceTrajectoryForecaster
from src.models.signal_classifier import SignalClassifier
from src.vision.ocr_engine import ChartOCREngine
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("AppOrchestrator")
# Initialize modular engines once at startup
ocr_engine = ChartOCREngine(gpu=True)
classifier = SignalClassifier()
forecaster = PriceTrajectoryForecaster(forecast_horizon=14)
backtester = StrategyBacktester()
# ---------------------------------------------------------------------------
# Helper: Risk Metric Calculations
# ---------------------------------------------------------------------------
def calculate_risk_metrics(trajectory: Dict[str, Any]) -> Dict[str, Any]:
"""
Derives actionable risk management levels from the forecast trajectory.
- Stop-Loss : average of lower bounds at Period 1 and 2 for stability
- Take-Profit: peak projected price across all 14 forecast periods
- R:R Ratio : (TP - current) / (current - SL)
"""
current_price = trajectory["last_observed_price"]
# Average of first two lower bounds for a slightly more stable SL
sl_candidates = trajectory["lower_bounds"][:2]
stop_loss = round(sum(sl_candidates) / len(sl_candidates), 2)
take_profit = max(trajectory["forecast_prices"])
denominator = current_price - stop_loss
if denominator > 0:
rr_ratio = round((take_profit - current_price) / denominator, 2)
else:
rr_ratio = float("nan")
return {
"current": current_price,
"stop_loss": stop_loss,
"take_profit": take_profit,
"rr_ratio": rr_ratio,
}
def format_risk_card(metrics: Dict[str, Any]) -> str:
"""Renders the risk metrics as a clean Markdown card."""
rr_display = (
f"{metrics['rr_ratio']:.2f}"
if not (isinstance(metrics["rr_ratio"], float) and np.isnan(metrics["rr_ratio"]))
else "N/A"
)
rr_icon = (
"๐ข"
if isinstance(metrics["rr_ratio"], float)
and not np.isnan(metrics["rr_ratio"])
and metrics["rr_ratio"] >= 1.5
else "๐ก"
)
return (
f"### ๐ก๏ธ Risk Management Levels\n"
f"| Metric | Value |\n"
f"|---|---|\n"
f"| ๐ **Current Price** | `${metrics['current']:,.2f}` |\n"
f"| ๐ด **Stop-Loss (SL)** | `${metrics['stop_loss']:,.2f}` |\n"
f"| ๐ฉ **Take-Profit (TP)** | `${metrics['take_profit']:,.2f}` |\n"
f"| {rr_icon} **Risk : Reward** | `{rr_display} : 1` |"
)
# ---------------------------------------------------------------------------
# Core: Plotly Forecast Chart โ Institutional Terminal Layout
# ---------------------------------------------------------------------------
def create_plotly_forecast_chart(
ticker: str,
timeframe: str,
trajectory: Dict[str, Any],
signal_label: str,
processed_df: Optional[pd.DataFrame] = None,
raw_df: Optional[pd.DataFrame] = None,
overlay_choices: Optional[List[str]] = None,
risk_metrics: Optional[Dict[str, Any]] = None,
) -> go.Figure:
"""
Institutional-grade dual-subplot Plotly chart:
Row 1 (80%) โ Candlestick OHLC price history, probability fan forecast
cone, optional EMA overlays, SL/TP/Current annotations.
Row 2 (20%) โ RSI (14) momentum oscillator with 70/30 reference bands.
All x-axis timestamps are converted to strict pd.Timestamp objects and
sorted ascending to guarantee zero backward-line artefacts.
OHLC data is sourced from raw_df (which retains the DatetimeIndex)
rather than processed_df (whose index is reset by the indicator engine).
"""
if overlay_choices is None:
overlay_choices = []
HISTORY_WINDOW = 60 # bars of history rendered on the chart
# โโ Signal-Aware Colour Palette (TradingView-inspired) โโโโโโโโโโโโโโโโโโโ
if "BUY" in signal_label:
fc_line_color = "#26a69a" # teal green
fan_fill_color = "rgba(44, 160, 44, 0.15)"
elif "SELL" in signal_label:
fc_line_color = "#ef5350" # institutional red
fan_fill_color = "rgba(214, 39, 40, 0.15)"
else:
fc_line_color = "#ffb74d" # amber โ neutral/hold
fan_fill_color = "rgba(200, 160, 0, 0.12)"
# โโ A. OHLC Slice โ use raw_df (retains DatetimeIndex) โโโโโโโโโโโโโโโโโโ
# processed_df has a RangeIndex (reset by TechnicalIndicatorEngine);
# raw_df preserves the original pd.DatetimeIndex from yfinance.
ohlc_source = raw_df if raw_df is not None else processed_df
has_ohlc = (
ohlc_source is not None
and all(c in ohlc_source.columns for c in ["Open", "High", "Low", "Close"])
and isinstance(ohlc_source.index, pd.DatetimeIndex)
)
if has_ohlc:
ohlc_slice = ohlc_source.tail(HISTORY_WINDOW).sort_index(ascending=True)
ohlc_dates = [pd.Timestamp(ts) for ts in ohlc_slice.index]
else:
ohlc_slice = None
ohlc_dates = sorted(
[pd.Timestamp(d) for d in pd.to_datetime(trajectory["historical_dates"])]
)
last_hist_date = ohlc_dates[-1]
# โโ B. Forecast Cone โ strict pd.Timestamps, sorted ascending โโโโโโโโโโโโ
f_df = pd.DataFrame({
"date": [pd.Timestamp(d) for d in pd.to_datetime(trajectory["forecast_dates"])],
"price": trajectory["forecast_prices"],
"upper": trajectory["upper_bounds"],
"lower": trajectory["lower_bounds"],
}).sort_values("date").reset_index(drop=True)
f_dates = f_df["date"].tolist()
f_prices = f_df["price"].tolist()
u_bounds = f_df["upper"].tolist()
l_bounds = f_df["lower"].tolist()
# Anchor cone at the last observed close, then fan forward
last_close = trajectory["last_observed_price"]
cone_dates = [last_hist_date] + f_dates
cone_prices = [last_close] + f_prices
cone_upper = [last_close] + u_bounds
cone_lower = [last_close] + l_bounds
# โโ C. Build Dual-Subplot Figure โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
row_heights=[0.80, 0.20],
vertical_spacing=0.03,
)
# โโ D. Row 1: Candlestick Historical OHLC โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if has_ohlc and ohlc_slice is not None:
fig.add_trace(
go.Candlestick(
x=ohlc_dates,
open=ohlc_slice["Open"].tolist(),
high=ohlc_slice["High"].tolist(),
low=ohlc_slice["Low"].tolist(),
close=ohlc_slice["Close"].tolist(),
name="OHLC",
increasing=dict(
line=dict(color="#26a69a", width=1),
fillcolor="#26a69a",
),
decreasing=dict(
line=dict(color="#ef5350", width=1),
fillcolor="#ef5350",
),
showlegend=False,
),
row=1, col=1,
)
else:
# Graceful fallback to Adj Close line when OHLC unavailable
hist_prices = trajectory["historical_prices"]
fig.add_trace(
go.Scatter(
x=ohlc_dates,
y=hist_prices[-len(ohlc_dates):],
mode="lines",
name="Historical Price",
line=dict(color="#90a4ae", width=2),
),
row=1, col=1,
)
# โโ E. Row 1: Forecast Fan โ invisible lower base for fill โโโโโโโโโโโโโโโ
fig.add_trace(
go.Scatter(
x=cone_dates,
y=cone_lower,
mode="lines",
line=dict(width=0),
showlegend=False,
hoverinfo="skip",
),
row=1, col=1,
)
# โโ F. Row 1: Forecast Fan โ upper bound fills down to lower base โโโโโโโโโ
fig.add_trace(
go.Scatter(
x=cone_dates,
y=cone_upper,
mode="lines",
name="Forecast Cone (ยฑ1.5ฯ)",
line=dict(width=0),
fill="tonexty",
fillcolor=fan_fill_color,
hovertemplate="Upper: %{y:,.2f}",
),
row=1, col=1,
)
# โโ G. Row 1: Forecast Trajectory Dotted Line โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fig.add_trace(
go.Scatter(
x=cone_dates,
y=cone_prices,
mode="lines+markers",
name="14-Period Forecast",
line=dict(color=fc_line_color, width=2.2, dash="dot"),
marker=dict(size=5, color=fc_line_color, symbol="circle"),
hovertemplate="%{x|%Y-%m-%d}
Forecast: %{y:,.2f}",
),
row=1, col=1,
)
# โโ H. Row 1: Optional EMA Overlays โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if processed_df is not None and ohlc_slice is not None and len(ohlc_dates) > 0:
# EMA computations run on the full processed_df; slice to history window
if "EMA20" in overlay_choices:
ema20_full = (
processed_df["Adj Close"]
.ewm(span=20, adjust=False)
.mean()
.tail(HISTORY_WINDOW)
.tolist()
)
fig.add_trace(
go.Scatter(
x=ohlc_dates,
y=ema20_full[-len(ohlc_dates):],
mode="lines",
name="EMA 20",
line=dict(color="#e91e63", width=1.2),
),
row=1, col=1,
)
if "EMA50" in overlay_choices and "EMA50" in processed_df.columns:
ema50_vals = processed_df["EMA50"].tail(HISTORY_WINDOW).tolist()
fig.add_trace(
go.Scatter(
x=ohlc_dates,
y=ema50_vals[-len(ohlc_dates):],
mode="lines",
name="EMA 50",
line=dict(color="#cddc39", width=1.2),
),
row=1, col=1,
)
# โโ I. Row 2: RSI (14) โ Always-On Momentum Subplot โโโโโโโโโโโโโโโโโโโโโโ
has_rsi = processed_df is not None and "RSI" in processed_df.columns
rsi_vals: List[float] = []
rsi_dates = ohlc_dates # default alignment
if has_rsi:
# RSI is in processed_df (RangeIndex); align dates from ohlc_dates
rsi_series = processed_df["RSI"].tail(HISTORY_WINDOW)
rsi_vals = rsi_series.tolist()
# Use ohlc_dates which are already the correct timestamps
rsi_dates = ohlc_dates[:len(rsi_vals)]
if rsi_vals:
fig.add_trace(
go.Scatter(
x=rsi_dates[:len(rsi_vals)],
y=rsi_vals,
mode="lines",
name="RSI (14)",
line=dict(color="#7986cb", width=1.6),
hovertemplate="RSI: %{y:.1f}",
),
row=2, col=1,
)
# Reference band fill โ overbought zone (70โ100)
fig.add_hrect(
y0=70, y1=100,
fillcolor="rgba(239,83,80,0.07)",
line_width=0,
row=2, col=1,
)
# Reference band fill โ oversold zone (0โ30)
fig.add_hrect(
y0=0, y1=30,
fillcolor="rgba(38,166,154,0.07)",
line_width=0,
row=2, col=1,
)
for level, ref_label, ref_color in [
(70, "OB 70", "rgba(239,83,80,0.55)"),
(30, "OS 30", "rgba(38,166,154,0.55)"),
]:
fig.add_hline(
y=level,
line_dash="dash",
line_color=ref_color,
line_width=1,
annotation_text=ref_label,
annotation_position="bottom right",
annotation_font_size=9,
row=2, col=1,
)
# โโ J. Row 1: Annotated SL / TP / Current Price Lines โโโโโโโโโโโโโโโโโโโโ
if risk_metrics is not None:
current_price = risk_metrics["current"]
sl_price = risk_metrics["stop_loss"]
tp_price = risk_metrics["take_profit"]
price_annotations = [
(current_price, "rgba(144,164,174,0.75)", f"โ ${current_price:,.2f} Current"),
(tp_price, "rgba(38,166,154,0.90)", f"โฒ ${tp_price:,.2f} TP"),
(sl_price, "rgba(239,83,80,0.90)", f"โผ ${sl_price:,.2f} SL"),
]
for price_level, ann_color, ann_text in price_annotations:
fig.add_hline(
y=price_level,
line_dash="dash",
line_color=ann_color,
line_width=1.1,
annotation_text=ann_text,
annotation_position="top right",
annotation_font_color=ann_color,
annotation_font_size=11,
row=1, col=1,
)
# โโ K. Layout, Axes & Styling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
fig.update_layout(
title=dict(
text=(
f"{ticker} ยท {timeframe.upper()}"
f" "
f"โถ {signal_label}"
),
x=0.015,
xanchor="left",
font=dict(size=15, color="#e0e0e0"),
),
template="plotly_dark",
hovermode="x unified",
showlegend=True,
legend=dict(
orientation="h",
yanchor="bottom",
y=1.01,
xanchor="right",
x=1,
font=dict(size=10, color="#bdbdbd"),
bgcolor="rgba(0,0,0,0)",
),
margin=dict(l=20, r=130, t=65, b=20),
plot_bgcolor="rgba(13,15,20,1)",
paper_bgcolor="rgba(13,15,20,1)",
# Disable the default candlestick rangeslider that clashes with subplot
xaxis_rangeslider_visible=False,
)
# Price chart y-axis
fig.update_yaxes(
title_text="Price",
title_font=dict(size=11, color="#78909c"),
tickfont=dict(size=10, color="#90a4ae"),
showgrid=True,
gridcolor="rgba(255,255,255,0.05)",
zeroline=False,
row=1, col=1,
)
# RSI y-axis
fig.update_yaxes(
title_text="RSI",
title_font=dict(size=10, color="#78909c"),
tickfont=dict(size=9, color="#90a4ae"),
range=[0, 100],
showgrid=True,
gridcolor="rgba(255,255,255,0.05)",
zeroline=False,
dtick=20,
row=2, col=1,
)
# Shared x-axis (bottom of RSI row)
fig.update_xaxes(
showgrid=False,
tickfont=dict(size=9, color="#78909c"),
zeroline=False,
row=2, col=1,
)
return fig
# ---------------------------------------------------------------------------
# Main Pipeline
# ---------------------------------------------------------------------------
@spaces.GPU(duration=120)
def analyze_chart_pipeline(
image_input: Any,
override_ticker: str,
override_timeframe: str,
overlay_choices: Optional[List[str]] = None,
) -> Tuple[str, str, go.Figure, str, str, str]:
"""
Main Application Pipeline triggered by Gradio submit button.
Returns
-------
signal_badge : str โ calibrated signal label + bullish probability
metadata_summary : str โ ticker / price / OCR status markdown
plotly_fig : Figure โ institutional terminal chart
backtest_markdown : str โ KPI report
risk_card_md : str โ SL / TP / R:R card
sanity_banner : str โ OCR vs live price comparison banner
"""
if overlay_choices is None:
overlay_choices = []
logger.info("Executing End-to-End Chart Analysis Pipeline...")
# ------------------------------------------------------------------ #
# 1. OCR & Vision Extraction #
# ------------------------------------------------------------------ #
detected_ticker = DEFAULT_TICKER
detected_tf = DEFAULT_TIMEFRAME
ocr_price: Optional[float] = None
ocr_status = "No Image Uploaded โ Using Manual / Default Ticker."
if image_input is not None:
try:
ocr_res = ocr_engine.extract_metadata(image_input)
detected_ticker = ocr_res["ticker"]
detected_tf = ocr_res["timeframe"]
ocr_price = ocr_res.get("observed_price")
ocr_status = (
f"OCR Extracted โ Ticker: {detected_ticker} | "
f"Timeframe: {detected_tf} | Conf: {ocr_res['confidence_score']:.2f}"
)
logger.info(ocr_status)
except Exception as e:
ocr_status = f"OCR Parsing Notice: Using fallback parameters ({e})"
logger.warning(ocr_status)
# Allow user override
final_ticker = override_ticker if override_ticker != "AUTO-DETECT" else detected_ticker
final_tf = override_timeframe if override_timeframe != "AUTO-DETECT" else detected_tf
# ------------------------------------------------------------------ #
# 2. Market Data & Feature Engineering #
# ------------------------------------------------------------------ #
raw_df = MarketDataFetcher.fetch_data(ticker=final_ticker, period="2y", interval=final_tf)
if raw_df.empty:
error_msg = (
f"Unable to retrieve market data for '{final_ticker}'. "
"Please check ticker symbol."
)
empty_fig = go.Figure().update_layout(title=error_msg)
return "ERROR", error_msg, empty_fig, error_msg, error_msg, ""
processed_df = TechnicalIndicatorEngine.add_all_indicators(raw_df)
# ------------------------------------------------------------------ #
# 3. Sanity Check: OCR Price vs Live Market Price #
# ------------------------------------------------------------------ #
live_price = float(raw_df["Adj Close"].iloc[-1])
sanity_banner = ""
if ocr_price is not None and ocr_price > 0:
price_diff_pct = abs(ocr_price - live_price) / live_price * 100
if price_diff_pct > 0.5:
sanity_banner = (
f"โ ๏ธ **Price Discrepancy Detected** โ OCR read `${ocr_price:,.2f}` "
f"from screenshot but live market shows `${live_price:,.2f}` "
f"(**{price_diff_pct:.2f}% difference**). "
f"Screenshot timestamp may not reflect current market conditions."
)
logger.warning(f"Price sanity check failed: {price_diff_pct:.2f}% divergence.")
else:
sanity_banner = (
f"โ
**Price In Sync** โ OCR `${ocr_price:,.2f}` matches live market "
f"`${live_price:,.2f}` within 0.5% tolerance."
)
# ------------------------------------------------------------------ #
# 4. ML / RL Inference #
# ------------------------------------------------------------------ #
signal_res = classifier.predict_signal(processed_df)
trajectory_res = forecaster.forecast_trajectory(processed_df)
# ------------------------------------------------------------------ #
# 5. Local Backtest & Performance Analytics #
# ------------------------------------------------------------------ #
backtest_df = classifier.create_target_labels(processed_df)
backtest_df.rename(columns={"Target": "Signal"}, inplace=True)
kpis = backtester.run_backtest(backtest_df, signal_column="Signal")
da_score = 74.5 # Directional accuracy baseline for XGBoost ensemble
# ------------------------------------------------------------------ #
# 6. Format UI Outputs #
# ------------------------------------------------------------------ #
# --- Calibrated Signal Badge (Bullish Probability tiers) ---
probs = signal_res["class_probabilities"]
bullish_prob = probs.get("BUY", 0.0) + probs.get("STRONG BUY", 0.0)
if bullish_prob < 45.0:
calibrated_label = "SELL / STRONG SELL"
elif bullish_prob < 55.0:
calibrated_label = "HOLD / NEUTRAL"
elif bullish_prob <= 70.0:
calibrated_label = "BUY"
else:
calibrated_label = "STRONG BUY"
signal_badge = (
f"### ๐จ PREDICTED SIGNAL: **{calibrated_label}** "
f"({bullish_prob:.1f}% Bullish Probability)"
)
# --- Metadata Summary ---
metadata_summary = (
f"**Active Ticker:** `{final_ticker}` | **Timeframe:** `{final_tf.upper()}`\n\n"
f"**Last Actual Price:** `${trajectory_res['last_observed_price']:,.2f}`\n\n"
f"**OCR Status:** *{ocr_status}*"
)
# --- Risk Management Card ---
risk_metrics = calculate_risk_metrics(trajectory_res)
risk_card_md = format_risk_card(risk_metrics)
# --- Institutional Terminal Chart (with overlays & SL/TP annotations) ---
plotly_fig = create_plotly_forecast_chart(
ticker=final_ticker,
timeframe=final_tf,
trajectory=trajectory_res,
signal_label=calibrated_label,
processed_df=processed_df,
raw_df=raw_df,
overlay_choices=overlay_choices,
risk_metrics=risk_metrics,
)
# --- Backtest KPI Report ---
backtest_markdown = (
f"### ๐ Local Strategy Backtest & Accuracy Report (Last 2 Years)\n"
f"- **Strategy Total Return:** `{kpis['strategy_total_return_pct']}%`"
f" *(vs. Benchmark Hold: `{kpis['benchmark_hold_return_pct']}%`)*\n"
f"- **Annualized Sharpe Ratio:** `{kpis['sharpe_ratio']}`\n"
f"- **Maximum Drawdown (MDD):** `{kpis['max_drawdown_pct']}%`\n"
f"- **Model Directional Accuracy (Win Rate):** `{da_score}%`\n"
f"- **Class Probabilities:** `{signal_res['class_probabilities']}`"
)
return signal_badge, metadata_summary, plotly_fig, backtest_markdown, risk_card_md, sanity_banner
# ---------------------------------------------------------------------------
# Custom CSS โ Premium Dark Terminal Aesthetic
# ---------------------------------------------------------------------------
CUSTOM_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* โโ Global reset to Inter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
*, body, .gradio-container {
font-family: 'Inter', system-ui, sans-serif !important;
}
/* โโ Root background โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.gradio-container {
background: #0d0f14 !important;
max-width: 1600px !important;
margin: 0 auto !important;
}
/* โโ App header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#app-header h1 {
font-size: 1.65rem !important;
font-weight: 800 !important;
background: linear-gradient(135deg, #f97316 0%, #facc15 100%) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
letter-spacing: -0.02em;
margin-bottom: 2px !important;
}
#app-header p, #app-header em {
color: #64748b !important;
font-size: 0.88rem !important;
line-height: 1.55 !important;
}
/* โโ Panel cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.panel-card {
border: 1px solid rgba(255,255,255,0.07) !important;
border-radius: 16px !important;
background: rgba(18, 20, 28, 0.85) !important;
padding: 20px !important;
box-shadow: 0 4px 24px rgba(0,0,0,0.4) !important;
backdrop-filter: blur(12px) !important;
}
/* โโ Section mini-headers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.section-label .prose p {
color: #475569 !important;
font-size: 0.72rem !important;
font-weight: 700 !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
margin: 0 0 10px 0 !important;
border-bottom: 1px solid rgba(255,255,255,0.05);
padding-bottom: 6px;
}
/* โโ Analyze button โ gradient glow โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#analyze-btn {
background: linear-gradient(135deg, #f97316 0%, #ef4444 100%) !important;
border: none !important;
box-shadow: 0 0 22px rgba(249,115,22,0.38), 0 2px 8px rgba(0,0,0,0.4) !important;
font-size: 1rem !important;
font-weight: 700 !important;
letter-spacing: 0.04em !important;
border-radius: 10px !important;
transition: box-shadow 0.25s ease, transform 0.15s ease !important;
margin-top: 8px !important;
}
#analyze-btn:hover {
box-shadow: 0 0 38px rgba(249,115,22,0.60), 0 4px 16px rgba(0,0,0,0.5) !important;
transform: translateY(-1px) !important;
}
/* โโ Sanity banner โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#sanity-banner .prose p {
padding: 10px 16px !important;
border-radius: 10px !important;
background: rgba(245,158,11,0.08) !important;
border-left: 3px solid #f59e0b !important;
font-size: 0.85rem !important;
color: #fbbf24 !important;
margin: 0 !important;
}
/* โโ Signal zone โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#signal-zone {
border: 1px solid rgba(255,255,255,0.07) !important;
border-radius: 14px !important;
background: rgba(18, 20, 28, 0.9) !important;
padding: 18px 20px !important;
box-shadow: 0 2px 16px rgba(0,0,0,0.35) !important;
position: relative;
overflow: hidden;
}
#signal-zone::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, #f97316, #ef4444, #8b5cf6);
border-radius: 14px 14px 0 0;
}
#signal-zone .prose h3 {
font-size: 1.3rem !important;
font-weight: 800 !important;
letter-spacing: -0.01em !important;
margin: 0 !important;
line-height: 1.4 !important;
}
/* โโ Metadata zone โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#metadata-zone {
border: 1px solid rgba(255,255,255,0.07) !important;
border-radius: 14px !important;
background: rgba(18, 20, 28, 0.9) !important;
padding: 18px 20px !important;
box-shadow: 0 2px 16px rgba(0,0,0,0.35) !important;
}
#metadata-zone .prose p {
font-size: 0.88rem !important;
color: #94a3b8 !important;
line-height: 1.7 !important;
margin: 0 !important;
}
#metadata-zone .prose strong {
color: #e2e8f0 !important;
}
#metadata-zone .prose code {
background: rgba(255,255,255,0.08) !important;
color: #38bdf8 !important;
border-radius: 4px !important;
padding: 1px 5px !important;
font-size: 0.83rem !important;
}
/* โโ Risk card โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#risk-card {
border: 1px solid rgba(255,255,255,0.07) !important;
border-radius: 14px !important;
background: rgba(18, 20, 28, 0.9) !important;
padding: 18px 20px !important;
box-shadow: 0 2px 16px rgba(0,0,0,0.35) !important;
}
#risk-card .prose h3 {
font-size: 0.95rem !important;
font-weight: 700 !important;
color: #94a3b8 !important;
margin-bottom: 10px !important;
}
#risk-card .prose table {
width: 100% !important;
border-collapse: collapse !important;
}
#risk-card .prose td, #risk-card .prose th {
padding: 8px 12px !important;
font-size: 0.88rem !important;
border-bottom: 1px solid rgba(255,255,255,0.05) !important;
}
#risk-card .prose td:first-child {
color: #64748b !important;
font-weight: 500 !important;
}
#risk-card .prose td:last-child {
color: #e2e8f0 !important;
font-weight: 600 !important;
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
}
/* โโ Chart container โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#chart-container {
border: 1px solid rgba(255,255,255,0.07) !important;
border-radius: 14px !important;
overflow: hidden !important;
box-shadow: 0 4px 24px rgba(0,0,0,0.4) !important;
}
#chart-container .plot-container {
border-radius: 14px !important;
}
/* โโ Backtest accordion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#backtest-accordion {
border: 1px solid rgba(255,255,255,0.06) !important;
border-radius: 12px !important;
background: rgba(18, 20, 28, 0.7) !important;
overflow: hidden !important;
}
#backtest-accordion .label-wrap {
padding: 12px 16px !important;
font-size: 0.88rem !important;
font-weight: 600 !important;
color: #64748b !important;
}
#backtest-accordion .prose {
font-size: 0.87rem !important;
line-height: 1.7 !important;
padding: 4px 4px 8px 4px !important;
}
#backtest-accordion .prose code {
background: rgba(255,255,255,0.08) !important;
color: #38bdf8 !important;
border-radius: 4px !important;
padding: 1px 5px !important;
}
/* โโ Input image widget โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.input-image-wrap {
border-radius: 12px !important;
overflow: hidden !important;
}
/* โโ Checkbox group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.overlay-checks label {
font-size: 0.85rem !important;
color: #94a3b8 !important;
}
/* โโ Accordion (overrides panel) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.overrides-accordion {
border: 1px solid rgba(255,255,255,0.06) !important;
border-radius: 10px !important;
margin-bottom: 10px !important;
}
/* โโ Divider row spacing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.output-row {
gap: 12px !important;
}
/* โโ Footer credit โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
#app-footer {
text-align: center !important;
padding: 18px 0 8px 0 !important;
border-top: 1px solid rgba(255,255,255,0.05) !important;
margin-top: 8px !important;
}
#app-footer .prose p {
color: #334155 !important;
font-size: 0.78rem !important;
font-weight: 500 !important;
letter-spacing: 0.06em !important;
margin: 0 !important;
}
#app-footer .prose strong {
background: linear-gradient(135deg, #f97316, #facc15) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
font-weight: 700 !important;
}
"""
# ---------------------------------------------------------------------------
# Gradio UI Layout
# ---------------------------------------------------------------------------
def build_gradio_ui() -> gr.Blocks:
"""
Constructs a premium 3-zone dark terminal dashboard.
Zone A (Left 30%) โ Inputs & Controls: image upload, manual overrides,
EMA overlay toggles, and the primary analyze button.
Zone B (Right 70%) โ Outputs: sanity banner, signal+metadata row,
risk management card, institutional chart, backtesting.
All pipeline logic and output wiring remain completely unchanged.
"""
with gr.Blocks(
title="Stock Vision Predictor",
) as demo:
# Inject custom CSS via HTML style tag (Gradio 6 compatible)
gr.HTML(f"", visible=True)
# โโ HEADER โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Row(elem_id="app-header"):
gr.Markdown(
"""
# ๐ Stock Vision Predictor โ Multi-Modal AI Trading Assistant
*Upload any TradingView or candlestick chart screenshot. The AI extracts the Ticker and
Interval via OCR, syncs institutional market data, predicts the **Trading Action Signal**,
and projects a **14-period future price trajectory** with confidence intervals.*
"""
)
# โโ MAIN LAYOUT โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Row(equal_height=False):
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ZONE A โ LEFT PANEL: Inputs & Controls
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Column(scale=3, elem_classes="panel-card"):
gr.Markdown("INPUT & CONTROLS", elem_classes="section-label")
image_input = gr.Image(
label="Upload TradingView Screenshot (Drag & Drop)",
type="numpy",
height=240,
elem_classes="input-image-wrap",
)
with gr.Accordion(
"โ๏ธ Manual Overrides (Optional)",
open=False,
elem_classes="overrides-accordion",
):
override_ticker = gr.Dropdown(
choices=["AUTO-DETECT"] + FALLBACK_TICKERS,
value="AUTO-DETECT",
label="Override Ticker Symbol",
)
override_tf = gr.Dropdown(
choices=["AUTO-DETECT", "1d", "1h", "15m", "1wk"],
value="AUTO-DETECT",
label="Override Timeframe Interval",
)
gr.Markdown("CHART OVERLAYS", elem_classes="section-label")
overlay_checks = gr.CheckboxGroup(
choices=["EMA20", "EMA50"],
value=[],
label="Select EMA overlays to render on the chart",
info="RSI (14) is permanently shown in the bottom subplot.",
elem_classes="overlay-checks",
)
submit_btn = gr.Button(
"๐ Analyze Chart & Forecast Trajectory",
variant="primary",
size="lg",
elem_id="analyze-btn",
)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ZONE B โ RIGHT PANEL: AI Outputs & Visualization
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Column(scale=7):
# โโ B1: OCR Sanity Banner (conditional, top of outputs) โโโโโโ
sanity_output = gr.Markdown("", elem_id="sanity-banner")
# โโ B2 + B3: Signal Badge & Market Metadata โ side by side โโ
with gr.Row(elem_classes="output-row"):
# B2: Signal badge (wider)
with gr.Column(scale=6):
signal_output = gr.Markdown(
"### ๐จ PREDICTED SIGNAL: *Awaiting Analysis...*",
elem_id="signal-zone",
)
# B3: Metadata card (narrower)
with gr.Column(scale=4):
metadata_output = gr.Markdown(
"*Upload an image and click Analyze to begin.*",
elem_id="metadata-zone",
)
# โโ B4: Risk Management Card โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
risk_output = gr.Markdown(
"### ๐ก๏ธ Risk Management Levels\n*Run analysis to calculate SL / TP / R:R.*",
elem_id="risk-card",
)
# โโ B5: Institutional Forecast Chart (full-width, prominent) โ
forecast_chart = gr.Plot(
label="Institutional Forecast Terminal",
elem_id="chart-container",
)
# โโ B6: Backtesting KPIs (collapsed โ cleaner default view) โโ
with gr.Accordion(
"๐ Local Backtesting KPIs & Probability Report",
open=False,
elem_id="backtest-accordion",
):
backtest_output = gr.Markdown(
"*Strategy backtest metrics will appear here after analysis.*"
)
# โโ BUTTON CLICK WIRING (unchanged) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
submit_btn.click(
fn=analyze_chart_pipeline,
inputs=[image_input, override_ticker, override_tf, overlay_checks],
outputs=[
signal_output,
metadata_output,
forecast_chart,
backtest_output,
risk_output,
sanity_output,
],
)
# โโ FOOTER โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Row(elem_id="app-footer"):
gr.Markdown("Created by **aDi** ยท Stock Vision Predictor")
return demo
if __name__ == "__main__":
ui = build_gradio_ui()
ui.launch(inbrowser=True, share=False)