184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
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("基本面卡片、新闻与情绪、宏观指标、策略筛选等将逐步接入。") |