Spaces:
Running
Running
David Chan Mun POON (SG)
feat: add absorption/scalper to vision analysis + show advanced summary in screenshot mode
2d12f96 | """ | |
| π¦ 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" | |
| ) | |