import streamlit as st import pandas as pd import plotly.express as px from datetime import datetime, timedelta def _check_openbb_installed() -> bool: try: # OpenBB v4 推荐用法: from openbb import obb from openbb import obb # noqa: F401 return True except Exception: return False def _load_price_data(symbol: str, days: int = 365) -> pd.DataFrame: """Fetch OHLCV using OpenBB v4 when available; otherwise return demo/synthetic data.""" end = datetime.utcnow().date() start = end - timedelta(days=days) # 优先使用 OpenBB v4 try: from openbb import obb # 先尝试股票路由 try: out = obb.equity.price.historical( symbol, start_date=str(start), end_date=str(end), ) except Exception: out = None # 若股票无数据,再尝试 ETF 路由 if out is None or (hasattr(out, "is_empty") and out.is_empty): try: out = obb.etf.price.historical( symbol, start_date=str(start), end_date=str(end), ) except Exception: out = None if out is not None: if hasattr(out, "to_df"): df = out.to_df() elif hasattr(out, "to_dataframe"): df = out.to_dataframe() else: # 兜底: 有些 provider 返回可序列化对象 df = pd.DataFrame(out) # type: ignore[arg-type] # 规格化列名 if not isinstance(df, pd.DataFrame) or df.empty: raise ValueError("OpenBB 返回空数据") # 有的表以 index 为日期 if 'date' in df.columns: df['Date'] = pd.to_datetime(df['date']) elif df.index.name in ('date', 'Date') or isinstance(df.index, pd.DatetimeIndex): df = df.copy() df['Date'] = pd.to_datetime(df.index) else: # 尝试查找常见日期列 for cand in ['timestamp', 'time', 'datetime']: if cand in df.columns: df['Date'] = pd.to_datetime(df[cand]) break # 归一化收盘价列 close_col = None for cand in ['adj_close', 'close', 'Close', 'price', 'close_price', 'c']: if cand in df.columns: close_col = cand break if close_col is None: raise ValueError("未找到收盘价列") df['Close'] = pd.to_numeric(df[close_col], errors='coerce') # 仅保留需要列并清洗 if 'Date' not in df.columns: raise ValueError("未找到日期列") df = df[['Date', 'Close']].dropna() df = df.sort_values('Date').reset_index(drop=True) # 限定时间窗口(有些 provider 可能返回更长区间) df = df[df['Date'].dt.date.between(start, end)] if df.empty: raise ValueError("清洗后为空") return df except Exception: # 如果 OpenBB 不可用或调用失败,进入本地演示/合成数据兜底 pass # Fallback to demo from examples/data try: from pathlib import Path root = Path(__file__).resolve().parents[2] demo_map = { 'AAPL': root / 'examples' / 'data' / 'demo_results_aapl.json', 'MSFT': root / 'examples' / 'data' / 'demo_results_msft.json', 'TSLA': root / 'examples' / 'data' / 'demo_results_tsla.json', } path = demo_map.get(symbol.upper()) if path and path.exists(): df = pd.read_json(path) if 'date' in df.columns: df['Date'] = pd.to_datetime(df['date']) if 'close' in df.columns: df['Close'] = df['close'] df = df[['Date', 'Close']].dropna().sort_values('Date').reset_index(drop=True) # 裁剪到时间窗口 df = df[df['Date'].dt.date.between(start, end)] return df except Exception: pass # Last resort: minimal synthetic data(避免 FutureWarning) dates = pd.date_range(end=end, periods=min(days, 180)) return pd.DataFrame({ 'Date': dates, 'Close': pd.Series(range(len(dates))).rolling(5).mean().bfill() }) def _kpis_from_df(df: pd.DataFrame) -> dict: if df.empty or 'Close' not in df.columns: return {"最新价": "-", "近30日涨幅": "-", "最大回撤(近90日)": "-"} latest = float(df['Close'].iloc[-1]) last_30 = df.tail(30) if len(last_30) > 1: pct_30 = (last_30['Close'].iloc[-1] / last_30['Close'].iloc[0] - 1) * 100 else: pct_30 = 0.0 # max drawdown over last 90 days lookback = df.tail(90)['Close'] roll_max = lookback.cummax() drawdown = (lookback / roll_max - 1).min() * 100 return { "最新价": f"{latest:,.2f}", "近30日涨幅": f"{pct_30:.2f}%", "最大回撤(近90日)": f"{drawdown:.2f}%", } def render_openbb_tab(): st.write("使用 OpenBB(如可用)或演示数据展示市场概览。") col_a, col_b = st.columns([2, 1]) with col_b: symbol = st.text_input("股票/ETF 代码", value="AAPL") days = st.slider("时间窗口(天)", 90, 720, 365, step=30) obb_ready = _check_openbb_installed() if obb_ready: st.success("OpenBB 已安装 ✅") else: st.info("未检测到 OpenBB,将使用演示数据。可在 requirements.txt 中加入 openbb 后安装启用。") with col_a: df = _load_price_data(symbol, days) if df is None or df.empty: st.warning("未获取到数据") return # 绘制收盘价 if 'Date' in df.columns and 'Close' in df.columns: fig = px.line(df, x='Date', y='Close', title=f"{symbol.upper()} 收盘价") st.plotly_chart(fig, use_container_width=True) else: st.dataframe(df.head()) # KPI 卡片 st.markdown("#### 关键指标") kpis = _kpis_from_df(df) k1, k2, k3 = st.columns(3) k1.metric("最新价", kpis["最新价"]) k2.metric("近30日涨幅", kpis["近30日涨幅"]) k3.metric("最大回撤(近90日)", kpis["最大回撤(近90日)"]) # 未来:基本面、新闻、情绪等组件占位 with st.expander("🚧 更多组件(即将推出)"): st.write("基本面卡片、新闻与情绪、宏观指标、策略筛选等将逐步接入。")