refactor(project): 重构项目文档并优化代码结构
- 移除旧的文档结构和内容,清理 root 目录下的 markdown 文件 - 删除 GitHub Pages 部署配置和相关文件 - 移除 .env.example 文件,使用 Doppler 进行环境变量管理 - 更新 README.md,增加对 OpenBB 数据的支持 - 重构 streamlit_app.py,移除 Swarm 模式相关代码 - 更新 Doppler 配置管理模块,增加对 .env 文件的支持 - 删除 Memory Bank 实验和测试脚本 - 清理内部文档和开发计划
This commit is contained in:
@@ -38,7 +38,8 @@ def show_header():
|
||||
with col2:
|
||||
st.metric("AI模型", "OpenRouter")
|
||||
with col3:
|
||||
st.metric("数据源", "RapidAPI")
|
||||
# 更新数据源展示,加入 OpenBB
|
||||
st.metric("数据源", "RapidAPI + OpenBB")
|
||||
|
||||
def show_sidebar():
|
||||
"""显示侧边栏"""
|
||||
@@ -68,15 +69,12 @@ def show_sidebar():
|
||||
|
||||
if st.button("🏛️ 启动八仙论道"):
|
||||
start_jixia_debate()
|
||||
|
||||
if st.button("🚀 启动Swarm论道"):
|
||||
start_swarm_debate()
|
||||
|
||||
def test_api_connections():
|
||||
"""测试API连接"""
|
||||
with st.spinner("正在测试API连接..."):
|
||||
try:
|
||||
from scripts.test_openrouter_api import test_openrouter_api, test_rapidapi_connection
|
||||
from scripts.api_health_check import test_openrouter_api, test_rapidapi_connection
|
||||
|
||||
openrouter_ok = test_openrouter_api()
|
||||
rapidapi_ok = test_rapidapi_connection()
|
||||
@@ -106,43 +104,6 @@ def start_jixia_debate():
|
||||
except Exception as e:
|
||||
st.error(f"❌ 辩论启动失败: {str(e)}")
|
||||
|
||||
def start_swarm_debate():
|
||||
"""启动Swarm八仙论道"""
|
||||
with st.spinner("正在启动Swarm八仙论道..."):
|
||||
try:
|
||||
import asyncio
|
||||
from src.jixia.debates.swarm_debate import start_ollama_debate, start_openrouter_debate
|
||||
|
||||
# 选择模式
|
||||
mode = st.session_state.get('swarm_mode', 'ollama')
|
||||
topic = st.session_state.get('swarm_topic', 'TSLA股价走势分析')
|
||||
|
||||
# 构建上下文
|
||||
context = {
|
||||
"market_sentiment": "谨慎乐观",
|
||||
"volatility": "中等",
|
||||
"technical_indicators": {
|
||||
"RSI": 65,
|
||||
"MACD": "金叉",
|
||||
"MA20": "上穿"
|
||||
}
|
||||
}
|
||||
|
||||
# 运行辩论
|
||||
if mode == 'ollama':
|
||||
result = asyncio.run(start_ollama_debate(topic, context))
|
||||
else:
|
||||
result = asyncio.run(start_openrouter_debate(topic, context))
|
||||
|
||||
if result:
|
||||
st.success("✅ Swarm八仙论道完成")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error("❌ Swarm辩论失败")
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"❌ Swarm辩论启动失败: {str(e)}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
configure_page()
|
||||
@@ -152,8 +113,8 @@ def main():
|
||||
# 主内容区域
|
||||
st.markdown("---")
|
||||
|
||||
# 选项卡
|
||||
tab1, tab2, tab3 = st.tabs(["🏛️ 稷下学宫", "🌍 天下体系", "📊 数据分析"])
|
||||
# 选项卡(新增 OpenBB 数据页签)
|
||||
tab1, tab2, tab3, tab4 = st.tabs(["🏛️ 稷下学宫", "🌍 天下体系", "📊 数据分析", "📈 OpenBB 数据"])
|
||||
|
||||
with tab1:
|
||||
st.markdown("### 🏛️ 稷下学宫 - 八仙论道")
|
||||
@@ -162,37 +123,18 @@ def main():
|
||||
# 辩论模式选择
|
||||
debate_mode = st.selectbox(
|
||||
"选择辩论模式",
|
||||
["传统模式 (RapidAPI数据)", "Swarm模式 (AI智能体)"],
|
||||
key="debate_mode_select"
|
||||
["ADK模式 (太上老君主持)", "传统模式 (RapidAPI数据)"]
|
||||
)
|
||||
|
||||
if debate_mode == "Swarm模式 (AI智能体)":
|
||||
# Swarm模式配置
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
swarm_mode = st.selectbox(
|
||||
"AI服务模式",
|
||||
["ollama", "openrouter"],
|
||||
key="swarm_mode_select"
|
||||
)
|
||||
st.session_state.swarm_mode = swarm_mode
|
||||
|
||||
with col2:
|
||||
swarm_topic = st.text_input(
|
||||
"辩论主题",
|
||||
value="英伟达股价走势:AI泡沫还是技术革命?",
|
||||
key="swarm_topic_input"
|
||||
)
|
||||
st.session_state.swarm_topic = swarm_topic
|
||||
|
||||
if st.button("🚀 启动Swarm八仙论道", type="primary"):
|
||||
start_swarm_debate()
|
||||
|
||||
if debate_mode == "ADK模式 (太上老君主持)":
|
||||
from app.tabs.adk_debate_tab import render_adk_debate_tab
|
||||
render_adk_debate_tab()
|
||||
|
||||
else:
|
||||
# 传统模式
|
||||
col1, col2 = st.columns([2, 1])
|
||||
with col1:
|
||||
topic = st.text_input("辩论主题 (股票代码)", value="TSLA", key="debate_topic")
|
||||
topic = st.text_input("辩论主题 (股票代码)", value="TSLA")
|
||||
with col2:
|
||||
if st.button("🎭 开始辩论", type="primary"):
|
||||
start_debate_session(topic)
|
||||
@@ -236,6 +178,14 @@ def main():
|
||||
except Exception as e:
|
||||
st.warning(f"⚠️ 无法加载统计数据: {str(e)}")
|
||||
|
||||
with tab4:
|
||||
st.markdown("### 📈 OpenBB 数据")
|
||||
try:
|
||||
from app.tabs.openbb_tab import render_openbb_tab
|
||||
render_openbb_tab()
|
||||
except Exception as e:
|
||||
st.error(f"❌ OpenBB 模块加载失败: {str(e)}")
|
||||
|
||||
def start_debate_session(topic: str):
|
||||
"""启动辩论会话"""
|
||||
if not topic:
|
||||
|
||||
171
app/tabs/adk_debate_tab.py
Normal file
171
app/tabs/adk_debate_tab.py
Normal file
@@ -0,0 +1,171 @@
|
||||
|
||||
import streamlit as st
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# Ensure the main project directory is in the Python path
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from google.adk import Agent, Runner
|
||||
from google.adk.sessions import InMemorySessionService, Session
|
||||
from google.genai import types
|
||||
|
||||
async def _get_llm_reply(runner: Runner, session: Session, prompt: str) -> str:
|
||||
"""Helper function to call a Runner and get a text reply."""
|
||||
content = types.Content(role='user', parts=[types.Part(text=prompt)])
|
||||
response = runner.run_async(
|
||||
user_id=session.user_id,
|
||||
session_id=session.id,
|
||||
new_message=content
|
||||
)
|
||||
|
||||
reply = ""
|
||||
async for event in response:
|
||||
if hasattr(event, 'content') and event.content and hasattr(event.content, 'parts'):
|
||||
for part in event.content.parts:
|
||||
if hasattr(part, 'text') and part.text:
|
||||
reply += str(part.text)
|
||||
elif hasattr(event, 'text') and event.text:
|
||||
reply += str(event.text)
|
||||
|
||||
return reply.strip()
|
||||
|
||||
async def run_adk_debate_streamlit(topic: str, participants: List[str], rounds: int):
|
||||
"""
|
||||
Runs the ADK turn-based debate and yields each statement for Streamlit display.
|
||||
"""
|
||||
try:
|
||||
yield "🚀 **启动ADK八仙轮流辩论 (太上老君主持)...**"
|
||||
|
||||
all_immortals = ["铁拐李", "吕洞宾", "何仙姑", "张果老", "蓝采和", "汉钟离", "韩湘子", "曹国舅"]
|
||||
if not participants:
|
||||
participants = all_immortals
|
||||
|
||||
character_configs = {
|
||||
"太上老君": {"name": "太上老君", "model": "gemini-1.5-pro", "instruction": "你是太上老君,天道化身,辩论的主持人。你的言辞沉稳、公正、充满智慧。你的任务是:1. 对辩论主题进行开场介绍。2. 在每轮开始时进行引导。3. 在辩论结束后,对所有观点进行全面、客观的总结。保持中立,不偏袒任何一方。"},
|
||||
"铁拐李": {"name": "铁拐李", "model": "gemini-1.5-flash", "instruction": "你是铁拐李,八仙中的逆向思维专家。你善于从批判和质疑的角度看问题,发言风格直接、犀利,但富有智慧。"},
|
||||
"吕洞宾": {"name": "吕洞宾", "model": "gemini-1.5-flash", "instruction": "你是吕洞宾,八仙中的理性分析者。你善于平衡各方观点,用理性和逻辑来分析问题,发言风格温和而深刻。"},
|
||||
"何仙姑": {"name": "何仙姑", "model": "gemini-1.5-flash", "instruction": "你是何仙姑,八仙中的风险控制专家。你总是从风险管理的角度思考问题,善于发现潜在危险,发言风格谨慎、细致。"},
|
||||
"张果老": {"name": "张果老", "model": "gemini-1.5-flash", "instruction": "你是张果老,八仙中的历史智慧者。你善于从历史数据中寻找规律和智慧,提供长期视角,发言风格沉稳、博学。"},
|
||||
"蓝采和": {"name": "蓝采和", "model": "gemini-1.5-flash", "instruction": "你是蓝采和,八仙中的创新思维者。你善于从新兴视角和非传统方法来看待问题,发言风格活泼、新颖。"},
|
||||
"汉钟离": {"name": "汉钟离", "model": "gemini-1.5-flash", "instruction": "你是汉钟离,八仙中的平衡协调者。你善于综合各方观点,寻求和谐统一的解决方案,发言风格平和、包容。"},
|
||||
"韩湘子": {"name": "韩湘子", "model": "gemini-1.5-flash", "instruction": "你是韩湘子,八仙中的艺术感知者。你善于从美学和感性的角度分析问题,发言风格优雅、感性。"},
|
||||
"曹国舅": {"name": "曹国舅", "model": "gemini-1.5-flash", "instruction": "你是曹国舅,八仙中的实务执行者。你关注实际操作和具体细节,发言风格务实、严谨。"}
|
||||
}
|
||||
|
||||
session_service = InMemorySessionService()
|
||||
session = await session_service.create_session(state={}, app_name="稷下学宫八仙论道系统-Streamlit", user_id="st_user")
|
||||
|
||||
runners: Dict[str, Runner] = {}
|
||||
for name, config in character_configs.items():
|
||||
if name == "太上老君" or name in participants:
|
||||
agent = Agent(name=config["name"], model=config["model"], instruction=config["instruction"])
|
||||
runners[name] = Runner(app_name="稷下学宫八仙论道系统-Streamlit", agent=agent, session_service=session_service)
|
||||
|
||||
host_runner = runners.get("太上老君")
|
||||
if not host_runner:
|
||||
yield "❌ **主持人太上老君初始化失败。**"
|
||||
return
|
||||
|
||||
yield f"🎯 **参与仙人**: {', '.join(participants)}"
|
||||
debate_history = []
|
||||
|
||||
# Opening statement
|
||||
opening_prompt = f"请为本次关于“{topic}”的辩论,发表一段公正、深刻的开场白,并宣布辩论开始。"
|
||||
opening_statement = await _get_llm_reply(host_runner, session, opening_prompt)
|
||||
yield f"👑 **太上老君**: {opening_statement}"
|
||||
|
||||
# Debate rounds
|
||||
for round_num in range(rounds):
|
||||
round_intro_prompt = f"请为第 {round_num + 1} 轮辩论说一段引导语。"
|
||||
round_intro = await _get_llm_reply(host_runner, session, round_intro_prompt)
|
||||
yield f"👑 **太上老君**: {round_intro}"
|
||||
|
||||
for name in participants:
|
||||
if name not in runners: continue
|
||||
|
||||
history_context = f"\n最近的论道内容:\n" + "\n".join([f"- {h}" for h in debate_history[-5:]]) if debate_history else ""
|
||||
prompt = f"论道主题: {topic}{history_context}\n\n请从你的角色特点出发,简洁地发表观点。"
|
||||
|
||||
reply = await _get_llm_reply(runners[name], session, prompt)
|
||||
yield f"🗣️ **{name}**: {reply}"
|
||||
|
||||
debate_history.append(f"{name}: {reply}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Summary
|
||||
summary_prompt = f"辩论已结束。以下是完整的辩论记录:\n\n{' '.join(debate_history)}\n\n请对本次辩论进行全面、公正、深刻的总结。"
|
||||
summary = await _get_llm_reply(host_runner, session, summary_prompt)
|
||||
yield f"👑 **太上老君**: {summary}"
|
||||
|
||||
for runner in runners.values():
|
||||
await runner.close()
|
||||
|
||||
yield "🎉 **ADK八仙轮流辩论完成!**"
|
||||
|
||||
except Exception as e:
|
||||
yield f"❌ **运行ADK八仙轮流辩论失败**: {e}"
|
||||
import traceback
|
||||
st.error(traceback.format_exc())
|
||||
|
||||
def render_adk_debate_tab():
|
||||
"""Renders the Streamlit UI for the ADK Debate tab."""
|
||||
st.markdown("### 🏛️ 八仙论道 (ADK版 - 太上老君主持)")
|
||||
|
||||
topic = st.text_input(
|
||||
"辩论主题",
|
||||
value="AI是否应该拥有创造力?",
|
||||
key="adk_topic_input"
|
||||
)
|
||||
|
||||
all_immortals = ["铁拐李", "吕洞宾", "何仙姑", "张果老", "蓝采和", "汉钟离", "韩湘子", "曹国舅"]
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
rounds = st.number_input("辩论轮数", min_value=1, max_value=5, value=1, key="adk_rounds_input")
|
||||
with col2:
|
||||
participants = st.multiselect(
|
||||
"选择参与的仙人 (默认全选)",
|
||||
options=all_immortals,
|
||||
default=all_immortals,
|
||||
key="adk_participants_select"
|
||||
)
|
||||
|
||||
if st.button("🚀 开始论道", key="start_adk_debate_button", type="primary"):
|
||||
if not topic:
|
||||
st.error("请输入辩论主题。")
|
||||
return
|
||||
if not participants:
|
||||
st.error("请至少选择一位参与的仙人。")
|
||||
return
|
||||
|
||||
st.markdown("---")
|
||||
st.markdown("#### 📜 论道实录")
|
||||
|
||||
# Placeholder for real-time output
|
||||
output_container = st.empty()
|
||||
full_log = ""
|
||||
|
||||
# Run the async debate function
|
||||
try:
|
||||
# Get a new event loop for the thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# Create a coroutine object
|
||||
coro = run_adk_debate_streamlit(topic, participants, rounds)
|
||||
|
||||
# Run the coroutine until completion
|
||||
for message in loop.run_until_complete(async_generator_to_list(coro)):
|
||||
full_log += message + "\n\n"
|
||||
output_container.markdown(full_log)
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"启动辩论时发生错误: {e}")
|
||||
|
||||
async def async_generator_to_list(async_gen):
|
||||
"""Helper to consume an async generator and return a list of its items."""
|
||||
return [item async for item in async_gen]
|
||||
184
app/tabs/openbb_tab.py
Normal file
184
app/tabs/openbb_tab.py
Normal file
@@ -0,0 +1,184 @@
|
||||
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("基本面卡片、新闻与情绪、宏观指标、策略筛选等将逐步接入。")
|
||||
Reference in New Issue
Block a user