David Chan Mun POON (SG)
feat: add absorption/scalper to vision analysis + show advanced summary in screenshot mode
2d12f96
Raw
History Blame Contribute Delete
21 kB
"""
🦞 OpenClaw Live Scanner β€” Real-time market scanning with AI trade validation.
"""
import streamlit as st
import pandas as pd
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
from src.scanner import scan_watchlist, WATCHLISTS, fetch_ticker_data, compute_indicators, compute_score
from src.tv_scanner import scan_tradingview
from src.ai_analyst import analyze_ticker, analyze_with_screenshot
# ──────────────────────────────────────────────
# Page Config
# ──────────────────────────────────────────────
st.set_page_config(
page_title="🦞 OpenClaw Live Scanner",
page_icon="🦞",
layout="wide",
)
st.title("🦞 OpenClaw Live Scanner")
st.caption("Real-time market scanning with AI-powered GO / NO GO signals")
# ──────────────────────────────────────────────
# Helper: Display AI result
# ──────────────────────────────────────────────
def _display_ai_result(ai_result: dict, selected_provider: str):
"""Display AI analysis result with friendly error handling."""
if ai_result.get("_parsed"):
action = ai_result.get("action", "HOLD")
confidence = ai_result.get("confidence", 0)
conf_pct = int(confidence * 100) if isinstance(confidence, float) and confidence <= 1 else int(confidence)
provider_used = ai_result.get("_provider_used", selected_provider)
# Show fallback notice
if provider_used != selected_provider:
st.info(f"⚑ Rate limit hit on {selected_provider} β€” used {provider_used} instead.")
# Analysis type badge
if ai_result.get("_analysis_type") == "vision+data":
st.caption("πŸ”¬ Vision + Data analysis")
st.markdown(f"**Signal:** {action} | **Confidence:** {conf_pct}%")
c1, c2, c3 = st.columns(3)
entry = ai_result.get("entry_price")
stop = ai_result.get("stop_loss")
target = ai_result.get("target_1")
if entry:
c1.metric("Entry", f"${entry:,.2f}")
if stop:
c2.metric("Stop Loss", f"${stop:,.2f}")
if target:
c3.metric("Target", f"${target:,.2f}")
# Time estimate
est_time = ai_result.get("estimated_timeframe", "")
if est_time:
st.markdown(f"⏱️ **Estimated Timeframe:** {est_time}")
# Visual patterns (from screenshot analysis)
patterns = ai_result.get("visual_patterns", [])
if patterns:
st.markdown(f"πŸ‘οΈ **Visual Patterns:** {', '.join(patterns)}")
st.markdown(f"**Reasoning:** {ai_result.get('reasoning', '')}")
key_levels = ai_result.get("key_levels", "")
if key_levels:
st.markdown(f"πŸ“Š **Key Levels:** {key_levels}")
risks = ai_result.get("risks", [])
if risks:
st.markdown("**Risks:** " + " | ".join(risks))
else:
# Parse failed β€” friendly error
reasoning = ai_result.get("reasoning", "")
if "rate" in reasoning.lower() or "429" in reasoning or "quota" in reasoning.lower():
st.warning("⚑ All AI providers are rate limited right now. Please wait a minute and try again.")
elif "401" in reasoning or "auth" in reasoning.lower():
st.warning("πŸ”‘ API key issue. Check your .env file has valid keys.")
else:
st.warning("⚠️ AI returned an unparseable response. Showing raw output:")
with st.expander("Raw response"):
st.code(ai_result.get("_raw", "No response"), language=None)
def _show_advanced_summary(absorption: dict, scalper: dict):
"""Show a dedicated advanced indicators box inside the AI analysis section."""
parts = []
# Absorption
if absorption:
abs_detected = absorption.get("absorption_detected", False)
abs_score = absorption.get("absorption_score", 0)
abs_bias = absorption.get("signal_bias", "neutral")
abs_events = absorption.get("events", [])
abs_emoji = {"bullish": "🟒", "bearish": "πŸ”΄", "neutral": "βšͺ"}.get(abs_bias, "βšͺ")
if abs_detected or abs_score > 0:
evt_str = ", ".join(e.replace("_", " ") for e in abs_events) if abs_events else "none"
parts.append(f"{abs_emoji} **Absorption Bubbles:** {abs_bias.title()} ({int(abs_score * 100)}%) β€” {evt_str}")
else:
parts.append("βšͺ **Absorption Bubbles:** No significant absorption detected")
# Scalper
if scalper:
sc_signal = scalper.get("signal", "neutral")
sc_conf = scalper.get("confidence", 0)
sc_dir = scalper.get("direction", "neutral")
sc_zone = scalper.get("zone", "neutral")
sc_reversal = scalper.get("reversal")
sc_emoji = {"buy": "πŸ”Ό", "sell": "πŸ”½", "neutral": "βž–"}.get(sc_signal, "βž–")
sc_parts = [f"{sc_emoji} **Pro Scalper:** {sc_signal.upper()} ({int(sc_conf * 100)}%)"]
sc_parts.append(f"Direction: {sc_dir.title()}")
if sc_zone != "neutral":
sc_parts.append(f"Zone: {sc_zone.title()}")
if sc_reversal:
sc_parts.append(f"Reversal: {sc_reversal.replace('_', ' ').title()}")
parts.append(" | ".join(sc_parts))
if parts:
st.markdown("---")
st.markdown("**πŸ”¬ Advanced Indicators:**")
for p in parts:
st.markdown(f" {p}")
st.markdown("---")
# ──────────────────────────────────────────────
# Sidebar
# ──────────────────────────────────────────────
st.sidebar.header("βš™οΈ Scanner Settings")
# Data source
data_source = st.sidebar.selectbox(
"Data Source",
options=["tradingview", "yfinance"],
index=0,
format_func=lambda x: {
"tradingview": "πŸ“Ί TradingView (near real-time)",
"yfinance": "πŸ“Š Yahoo Finance (15-min delay)",
}[x],
help="TradingView provides near real-time data with pre-computed indicators.",
)
# Watchlist selection
watchlist_name = st.sidebar.selectbox(
"Watchlist",
options=list(WATCHLISTS.keys()) + ["Custom"],
index=0,
)
if watchlist_name == "Custom":
custom_tickers = st.sidebar.text_input(
"Tickers (comma-separated)",
value="NVDA, AAPL, BTC-USD",
help="Enter stock/crypto tickers separated by commas",
)
tickers = [t.strip().upper() for t in custom_tickers.split(",") if t.strip()]
else:
tickers = WATCHLISTS[watchlist_name]
# Auto-select market based on watchlist
default_market_map = {
"US Tech": "america",
"Crypto": "crypto",
"Forex": "forex",
"SGX": "singapore",
"Custom": "america",
}
default_market = default_market_map.get(watchlist_name, "america")
# Timeframe
interval = st.sidebar.selectbox(
"Timeframe",
options=["15m", "30m", "1h", "4h", "1d", "1wk"],
index=4,
format_func=lambda x: {
"15m": "15 Minutes (day trade)",
"30m": "30 Minutes (day trade)",
"1h": "1 Hour (intraday)",
"4h": "4 Hour (swing)",
"1d": "Daily (swing)",
"1wk": "Weekly (position)",
}[x],
)
period_map = {"15m": "1d", "30m": "2d", "1h": "5d", "4h": "1mo", "1d": "3mo", "1wk": "1y"}
period = period_map[interval]
# TradingView market (only shown when TV is selected)
tv_market = "america"
if data_source == "tradingview":
market_options = ["america", "crypto", "forex", "singapore"]
tv_market = st.sidebar.selectbox(
"Market",
options=market_options,
index=market_options.index(default_market),
format_func=lambda x: {
"america": "πŸ‡ΊπŸ‡Έ US Stocks",
"crypto": "β‚Ώ Crypto",
"forex": "πŸ’± Forex",
"singapore": "πŸ‡ΈπŸ‡¬ SGX",
}[x],
)
# AI Provider
ai_provider = st.sidebar.selectbox(
"AI Provider",
options=["google", "openrouter", "huggingface"],
index=0,
format_func=lambda x: {
"google": "Google Gemini (free)",
"openrouter": "OpenRouter Gemma (free)",
"huggingface": "HuggingFace Qwen (free)",
}[x],
help="Auto-falls back to next provider if rate limited.",
)
st.sidebar.divider()
st.sidebar.caption(f"Scanning {len(tickers)} tickers | {interval} timeframe")
# ──────────────────────────────────────────────
# Main: Scanner
# ──────────────────────────────────────────────
# Scan button
col_scan, col_time = st.columns([1, 2])
with col_scan:
scan_clicked = st.button("πŸ” Scan Now", type="primary", use_container_width=True)
with col_time:
st.caption(f"Last scan: {st.session_state.get('last_scan_time', 'Never')}")
# Clear old results if settings changed
current_settings = f"{data_source}_{watchlist_name}_{interval}_{tv_market}"
if st.session_state.get("_last_settings") != current_settings:
st.session_state.scan_results = []
st.session_state._last_settings = current_settings
if scan_clicked:
with st.spinner(f"πŸ“‘ Scanning {len(tickers)} tickers via {data_source}..."):
if data_source == "tradingview":
# Map interval for TradingView
tv_interval = {"15m": "15m", "30m": "30m", "1d": "1d", "1h": "1h", "4h": "4h", "1wk": "1w"}.get(interval, "1d")
results = scan_tradingview(tickers, market=tv_market, interval=tv_interval)
else:
results = scan_watchlist(tickers, period=period, interval=interval)
for r in results:
r["source"] = "yfinance"
st.session_state.scan_results = results
st.session_state.last_scan_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Display results
results = st.session_state.get("scan_results", [])
if not results:
st.info("πŸ‘† Click **Scan Now** to analyze your watchlist.")
else:
# Summary metrics
go_count = sum(1 for r in results if r["signal"] == "GO")
wait_count = sum(1 for r in results if r["signal"] == "WAIT")
nogo_count = sum(1 for r in results if r["signal"] == "NO GO")
m1, m2, m3, m4 = st.columns(4)
m1.metric("Total Scanned", len(results))
m2.metric("🟒 GO", go_count)
m3.metric("🟑 WAIT", wait_count)
m4.metric("πŸ”΄ NO GO", nogo_count)
# Show data source
source_label = results[0].get("source", "unknown") if results else "unknown"
source_emoji = {"tradingview": "πŸ“Ί", "yfinance": "πŸ“Š"}.get(source_label, "")
st.caption(f"Data source: {source_emoji} {source_label.title()}")
st.divider()
# Results table
for r in results:
ticker = r["ticker"]
score = r["score"]
signal = r["signal"]
ind = r["indicators"]
if "error" in ind:
st.markdown(f"**{ticker}** β€” ⚠️ Error: {ind['error']}")
continue
# Signal badge
signal_emoji = {"GO": "🟒", "WAIT": "🟑", "NO GO": "πŸ”΄", "ERROR": "⚠️"}.get(signal, "βšͺ")
signal_color = {"GO": "#1a3a1a", "WAIT": "#3a3a1a", "NO GO": "#3a1a1a"}.get(signal, "#1a1a1a")
border_color = {"GO": "green", "WAIT": "orange", "NO GO": "red"}.get(signal, "gray")
# Change color
change = ind.get("change_pct", 0)
change_str = f"+{change}%" if change >= 0 else f"{change}%"
change_color = "green" if change >= 0 else "red"
# Card
with st.container():
st.markdown(
f"""<div style="background-color: {signal_color}; padding: 15px; border-radius: 8px;
margin-bottom: 10px; border-left: 4px solid {border_color};">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-size: 1.3em; font-weight: bold;">{ticker}</span>
<span style="margin-left: 15px; font-size: 1.1em;">${ind.get('current_price', 'N/A')}</span>
<span style="margin-left: 10px; color: {change_color};">{change_str}</span>
</div>
<div style="text-align: right;">
<span style="font-size: 1.5em;">{signal_emoji} {signal}</span>
<span style="margin-left: 15px; font-size: 1.1em;">Score: {score}/10</span>
</div>
</div>
<div style="margin-top: 8px; font-size: 0.85em; color: #aaa;">
RSI: {ind.get('rsi', 'N/A')} |
MACD: {'Bullish' if ind.get('macd_bullish') else 'Bearish'} |
Trend: {ind.get('trend', 'N/A').replace('_', ' ').title()} |
Vol: {f"{ind['volume_ratio']}x avg" if ind.get('volume_ratio') else 'N/A'}
</div>
</div>""",
unsafe_allow_html=True,
)
# ── Advanced Indicators: Absorption Bubbles + Pro Scalper ──
absorption = r.get("absorption", {})
scalper = r.get("scalper", {})
adv_parts = []
# Absorption Bubbles display β€” always show status
if absorption:
if absorption.get("absorption_detected"):
abs_bias = absorption.get("signal_bias", "neutral")
abs_emoji = {"bullish": "🟒", "bearish": "πŸ”΄", "neutral": "βšͺ"}.get(abs_bias, "βšͺ")
abs_label = f"{abs_emoji} Absorption: {abs_bias.title()}"
# Show strength if available
buy_str = absorption.get("recent_buying_strength", 0)
sell_str = absorption.get("recent_selling_strength", 0)
if buy_str > 0:
abs_label += f" (Buy: {buy_str}Οƒ)"
if sell_str > 0:
abs_label += f" (Sell: {sell_str}Οƒ)"
# Show events
events = absorption.get("events", [])
if events:
abs_label += f" [{', '.join(e.replace('_', ' ') for e in events[:2])}]"
adv_parts.append(abs_label)
elif absorption.get("absorption_score", 0) > 0:
abs_bias = absorption.get("signal_bias", "neutral")
abs_emoji = {"bullish": "🟒", "bearish": "πŸ”΄", "neutral": "βšͺ"}.get(abs_bias, "βšͺ")
score_pct = int(absorption.get("absorption_score", 0) * 100)
events = absorption.get("events", [])
evt_str = f" [{', '.join(e.replace('_', ' ') for e in events[:2])}]" if events else ""
adv_parts.append(f"{abs_emoji} Absorption: {abs_bias.title()} ({score_pct}%){evt_str}")
else:
adv_parts.append("βšͺ Absorption: None detected")
# Pro Scalper display β€” always show status
if scalper:
sc_signal = scalper.get("signal", "neutral")
sc_conf = scalper.get("confidence", 0)
sc_emoji = {"buy": "πŸ”Ό", "sell": "πŸ”½", "neutral": "βž–"}.get(sc_signal, "βž–")
sc_label = f"{sc_emoji} Scalper: {sc_signal.upper()} ({int(sc_conf * 100)}%)"
direction = scalper.get("direction", "neutral")
if direction != "neutral":
sc_label += f" | Dir: {direction.title()}"
zone = scalper.get("zone", "neutral")
if zone != "neutral":
zone_emoji = {"overbought": "πŸ”₯", "oversold": "❄️"}.get(zone, "")
sc_label += f" | {zone_emoji} {zone.title()}"
reversal = scalper.get("reversal")
if reversal:
rev_emoji = "↩️" if "bullish" in reversal else "β†ͺ️"
sc_label += f" | {rev_emoji} {reversal.replace('_', ' ').title()}"
adv_parts.append(sc_label)
if adv_parts:
st.markdown(
f"<div style='font-size: 0.82em; color: #ccc; margin-top: -5px; margin-bottom: 8px; padding-left: 15px;'>"
f"{' β€’ '.join(adv_parts)}</div>",
unsafe_allow_html=True,
)
# Expand for AI analysis (available for all signals)
with st.expander(f"πŸ€– Get AI Analysis for {ticker}"):
# Two options: data-only or data + screenshot
analysis_tab = st.radio(
"Analysis type",
options=["πŸ“Š Data Only", "πŸ“Έ Data + Screenshot"],
horizontal=True,
key=f"tab_{ticker}",
label_visibility="collapsed",
)
if analysis_tab == "πŸ“Έ Data + Screenshot":
from streamlit_paste_button import paste_image_button
import io as _io
paste_result = paste_image_button(
label="πŸ“‹ Paste chart screenshot from clipboard",
text_color="#ffffff",
background_color="#ff6b35",
hover_background_color="#e55a2b",
key=f"paste_{ticker}",
)
if paste_result.image_data is not None:
buf = _io.BytesIO()
paste_result.image_data.save(buf, format="PNG")
image_bytes = buf.getvalue()
st.image(image_bytes, caption=f"{ticker} chart", use_container_width=True)
if st.button(f"πŸ”¬ Analyze {ticker} with Chart", key=f"vision_{ticker}"):
with st.spinner(f"🧠 AI analyzing {ticker} (vision + data)..."):
tf_label = {
"15m": "15-minute", "30m": "30-minute",
"1h": "1-hour", "4h": "4-hour",
"1d": "Daily", "1wk": "Weekly",
}.get(interval, "Daily")
ai_result = analyze_with_screenshot(
ticker=ticker,
indicators=ind,
score=score,
image_bytes=image_bytes,
provider=ai_provider,
timeframe=tf_label,
absorption=r.get("absorption"),
scalper=r.get("scalper"),
)
_show_advanced_summary(r.get("absorption", {}), r.get("scalper", {}))
_display_ai_result(ai_result, ai_provider)
else:
if st.button(f"Analyze {ticker}", key=f"ai_{ticker}"):
with st.spinner(f"🧠 AI analyzing {ticker}..."):
tf_label = {
"15m": "15-minute", "30m": "30-minute",
"1h": "1-hour", "4h": "4-hour",
"1d": "Daily", "1wk": "Weekly",
}.get(interval, "Daily")
ai_result = analyze_ticker(
ticker=ticker,
indicators=ind,
score=score,
provider=ai_provider,
timeframe=tf_label,
absorption=r.get("absorption"),
scalper=r.get("scalper"),
)
# Show advanced indicator summary before AI result
_show_advanced_summary(r.get("absorption", {}), r.get("scalper", {}))
_display_ai_result(ai_result, ai_provider)
# ──────────────────────────────────────────────
# Footer
# ──────────────────────────────────────────────
st.divider()
st.caption(
"🦞 OpenClaw Live Scanner v1.0 | "
"AI-powered market scanning | "
"⚠️ Not financial advice β€” always do your own research"
)