Skip to content

Commit 528e2e9

Browse files
authored
Merge branch 'main' into codex/integrate-futu-api-for-data-collection-6d6t47
2 parents 6db110f + 8c495fd commit 528e2e9

File tree

8 files changed

+481
-1
lines changed

8 files changed

+481
-1
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Python Package using Conda
2+
3+
on: [push]
4+
5+
jobs:
6+
build-linux:
7+
runs-on: ubuntu-latest
8+
strategy:
9+
max-parallel: 5
10+
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Set up Python 3.10
14+
uses: actions/setup-python@v3
15+
with:
16+
python-version: '3.10'
17+
- name: Add conda to system path
18+
run: |
19+
# $CONDA is an environment variable pointing to the root of the miniconda directory
20+
echo $CONDA/bin >> $GITHUB_PATH
21+
- name: Install dependencies
22+
run: |
23+
conda env update --file environment.yml --name base
24+
- name: Lint with flake8
25+
run: |
26+
conda install flake8
27+
# stop the build if there are Python syntax errors or undefined names
28+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
29+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
30+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
31+
- name: Test with pytest
32+
run: |
33+
conda install pytest
34+
pytest

environment.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: trading-dashboard
2+
channels:
3+
- defaults
4+
- conda-forge
5+
dependencies:
6+
- python>=3.10
7+
- streamlit>=1.32
8+
- pandas>=2.2
9+
- numpy>=1.26
10+
- httpx>=0.27
11+
- python-dotenv>=1.0
12+
- pip
13+
- pip:
14+
- futu-api>=7.2.4600
15+
- pydantic>=2.7
16+
- openai>=1.3
17+
- pytest>=7.4

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ dependencies = [
1111
"pydantic>=2.7",
1212
"openai>=1.3",
1313
"httpx>=0.27",
14-
"python-dotenv>=1.0"
14+
"python-dotenv>=1.0",
15+
"streamlit>=1.32",
16+
"pandas>=2.2",
17+
"numpy>=1.26"
1518
]
1619

1720
[project.optional-dependencies]

trading_dashboard/app.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Streamlit entry-point for the trading dashboard."""
2+
from __future__ import annotations
3+
4+
import pandas as pd
5+
import streamlit as st
6+
7+
from .executor import PaperTradeExecutor
8+
from .fetcher import MockMarketDataFetcher
9+
from .llm_client import LLMClient
10+
from .prompt_builder import PromptBuilder
11+
12+
st.set_page_config(page_title="Trading Dashboard", layout="wide", page_icon="📈")
13+
14+
15+
def _init_state() -> None:
16+
if "fetcher" not in st.session_state:
17+
st.session_state.fetcher = MockMarketDataFetcher()
18+
if "executor" not in st.session_state:
19+
st.session_state.executor = PaperTradeExecutor()
20+
if "llm_client" not in st.session_state:
21+
st.session_state.llm_client = LLMClient()
22+
if "prompt_builder" not in st.session_state:
23+
st.session_state.prompt_builder = PromptBuilder()
24+
25+
26+
def _refresh_snapshot() -> None:
27+
fetcher: MockMarketDataFetcher = st.session_state.fetcher
28+
executor: PaperTradeExecutor = st.session_state.executor
29+
llm_client: LLMClient = st.session_state.llm_client
30+
prompt_builder: PromptBuilder = st.session_state.prompt_builder
31+
32+
market = fetcher.fetch_latest()
33+
account = executor.get_account_state(market.price)
34+
prompt = prompt_builder.build_prompt(market, account)
35+
decision = llm_client.get_trading_decision(prompt)
36+
37+
st.session_state.market = market
38+
st.session_state.account = account
39+
st.session_state.prompt = prompt
40+
st.session_state.decision = decision
41+
42+
43+
def _render_market_panel() -> None:
44+
market = st.session_state.market
45+
st.subheader("🔹 行情面板")
46+
cols = st.columns(5)
47+
cols[0].metric("价格", f"{market.price:.2f}")
48+
cols[1].metric("EMA(20)", f"{market.ema:.2f}")
49+
cols[2].metric("MACD", f"{market.macd:.4f}")
50+
cols[3].metric("MACD Signal", f"{market.macd_signal:.4f}")
51+
cols[4].metric("RSI", f"{market.rsi:.2f}")
52+
53+
st.metric("成交量", f"{market.volume:,.0f}")
54+
55+
history = market.history.copy()
56+
history.set_index("time", inplace=True)
57+
history.index = pd.to_datetime(history.index)
58+
st.line_chart(history["close"], height=240)
59+
60+
61+
def _render_account_panel() -> None:
62+
account = st.session_state.account
63+
st.subheader("🔹 账户面板")
64+
cols = st.columns(3)
65+
cols[0].metric("账户价值", f"{account.total_value:,.2f}")
66+
cols[1].metric("现金", f"{account.cash:,.2f}")
67+
positions = ", ".join(f"{symbol}: {qty}" for symbol, qty in account.positions.items()) or "无"
68+
cols[2].metric("持仓", positions)
69+
70+
71+
def _render_ai_panel() -> None:
72+
st.subheader("🔹 AI 决策展示")
73+
st.markdown(
74+
"当前策略建议:"
75+
f"<span style='font-size:28px; font-weight:bold;'> {st.session_state.decision}</span>",
76+
unsafe_allow_html=True,
77+
)
78+
with st.expander("查看 Prompt"):
79+
st.code(st.session_state.prompt)
80+
81+
82+
def _render_order_panel() -> None:
83+
st.subheader("🔹 模拟下单")
84+
market = st.session_state.market
85+
executor: PaperTradeExecutor = st.session_state.executor
86+
action = st.selectbox(
87+
"选择操作",
88+
["Buy", "Sell", "Hold"],
89+
index=["Buy", "Sell", "Hold"].index(st.session_state.decision),
90+
)
91+
if st.button("执行下单"):
92+
result = executor.execute(action, market)
93+
st.success(f"{result.message} 数量: {result.quantity}, 价格: {result.price:.2f}")
94+
st.session_state.account = executor.get_account_state(market.price)
95+
96+
if executor.order_history:
97+
st.markdown("**订单历史**")
98+
history_data = [
99+
{
100+
"时间": order.timestamp.strftime("%H:%M:%S"),
101+
"操作": order.action,
102+
"数量": order.quantity,
103+
"价格": f"{order.price:.2f}",
104+
"备注": order.message,
105+
}
106+
for order in reversed(executor.order_history[-10:])
107+
]
108+
st.table(history_data)
109+
110+
111+
def main() -> None:
112+
st.markdown("<meta http-equiv='refresh' content='180'>", unsafe_allow_html=True)
113+
st.title("🚀 AI Trading Dashboard")
114+
st.caption("最小可行闭环:实时行情 + 账户状态 + AI 策略 + 一键下单")
115+
116+
_init_state()
117+
118+
_refresh_snapshot()
119+
120+
if st.button("手动刷新"):
121+
_refresh_snapshot()
122+
123+
col1, col2 = st.columns([2, 1])
124+
with col1:
125+
_render_market_panel()
126+
with col2:
127+
_render_account_panel()
128+
129+
col3, col4 = st.columns(2)
130+
with col3:
131+
_render_ai_panel()
132+
with col4:
133+
_render_order_panel()
134+
135+
136+
if __name__ == "__main__":
137+
main()

trading_dashboard/executor.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Paper trading execution utilities."""
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass, field
5+
from datetime import datetime
6+
from typing import Dict, List
7+
8+
from .fetcher import AccountState, MarketSnapshot
9+
10+
11+
@dataclass
12+
class OrderResult:
13+
action: str
14+
quantity: float
15+
price: float
16+
message: str
17+
timestamp: datetime
18+
19+
20+
@dataclass
21+
class PaperTradeExecutor:
22+
"""Minimal in-memory execution engine used by the dashboard."""
23+
24+
symbol: str = "AAPL"
25+
lot_size: float = 1.0
26+
cash: float = 100_000.0
27+
positions: Dict[str, float] = field(default_factory=dict)
28+
order_history: List[OrderResult] = field(default_factory=list)
29+
30+
def get_account_state(self, market_price: float) -> AccountState:
31+
position_qty = self.positions.get(self.symbol, 0.0)
32+
position_value = position_qty * market_price
33+
total_value = self.cash + position_value
34+
return AccountState(
35+
total_value=total_value,
36+
cash=self.cash,
37+
positions={self.symbol: position_qty},
38+
)
39+
40+
def execute(self, action: str, market: MarketSnapshot) -> OrderResult:
41+
action = action.capitalize()
42+
if action not in {"Buy", "Sell", "Hold"}:
43+
raise ValueError(f"Unsupported action: {action}")
44+
45+
qty = self.lot_size
46+
price = market.price
47+
position_qty = self.positions.get(self.symbol, 0.0)
48+
49+
now = datetime.utcnow()
50+
51+
if action == "Buy":
52+
cost = qty * price
53+
if cost > self.cash:
54+
result = OrderResult(action, 0.0, price, "Insufficient cash to buy.", now)
55+
else:
56+
self.cash -= cost
57+
self.positions[self.symbol] = position_qty + qty
58+
result = OrderResult(action, qty, price, "Filled buy order.", now)
59+
elif action == "Sell":
60+
if position_qty < qty:
61+
result = OrderResult(action, 0.0, price, "Not enough holdings to sell.", now)
62+
else:
63+
self.cash += qty * price
64+
self.positions[self.symbol] = position_qty - qty
65+
result = OrderResult(action, qty, price, "Filled sell order.", now)
66+
else: # Hold
67+
result = OrderResult(action, 0.0, price, "No trade executed.", now)
68+
69+
self.order_history.append(result)
70+
return result
71+
72+
73+
__all__ = ["OrderResult", "PaperTradeExecutor"]

trading_dashboard/fetcher.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Data fetching utilities for the Streamlit trading dashboard.
2+
3+
This module currently provides a mock implementation that simulates price
4+
movements so the dashboard can run end-to-end without external market data
5+
providers. The API has been designed so a real implementation can replace the
6+
mock with minimal changes.
7+
"""
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass
11+
from datetime import datetime, timedelta
12+
from typing import Dict
13+
14+
import numpy as np
15+
import pandas as pd
16+
17+
18+
@dataclass
19+
class MarketSnapshot:
20+
"""Aggregated view of the latest market indicators."""
21+
22+
symbol: str
23+
price: float
24+
ema: float
25+
macd: float
26+
macd_signal: float
27+
rsi: float
28+
volume: float
29+
history: pd.DataFrame
30+
31+
32+
@dataclass
33+
class AccountState:
34+
"""Basic representation of the account used in the dashboard."""
35+
36+
total_value: float
37+
cash: float
38+
positions: Dict[str, float]
39+
40+
41+
class MockMarketDataFetcher:
42+
"""Simulate a live market data feed.
43+
44+
The fetcher maintains an in-memory price series and generates technical
45+
indicators – EMA, MACD, RSI, and volume – each time ``fetch_latest`` is
46+
called. The historical data is kept in a pandas ``DataFrame`` to make chart
47+
rendering straightforward inside Streamlit.
48+
"""
49+
50+
def __init__(self, symbol: str = "AAPL", history_points: int = 120) -> None:
51+
self.symbol = symbol
52+
self.history_points = history_points
53+
self._history = self._generate_initial_history()
54+
55+
def _generate_initial_history(self) -> pd.DataFrame:
56+
end = datetime.utcnow()
57+
index = [end - timedelta(minutes=i) for i in reversed(range(self.history_points))]
58+
base_price = 150.0
59+
noise = np.random.normal(0, 0.5, size=self.history_points).cumsum()
60+
close = base_price + noise
61+
volume = np.random.randint(1_000, 10_000, size=self.history_points)
62+
return pd.DataFrame({"time": index, "close": close, "volume": volume})
63+
64+
def _append_new_row(self) -> None:
65+
last_price = float(self._history.iloc[-1]["close"])
66+
drift = np.random.normal(0, 0.8)
67+
new_price = max(0.01, last_price + drift)
68+
new_row = {
69+
"time": datetime.utcnow(),
70+
"close": new_price,
71+
"volume": int(np.random.randint(1_000, 10_000)),
72+
}
73+
self._history = pd.concat([self._history.iloc[1:], pd.DataFrame([new_row])], ignore_index=True)
74+
75+
def _compute_indicators(self, history: pd.DataFrame) -> Dict[str, float]:
76+
closes = history["close"]
77+
ema = closes.ewm(span=20, adjust=False).mean()
78+
ema12 = closes.ewm(span=12, adjust=False).mean()
79+
ema26 = closes.ewm(span=26, adjust=False).mean()
80+
macd = ema12 - ema26
81+
signal = macd.ewm(span=9, adjust=False).mean()
82+
83+
delta = closes.diff()
84+
gain = delta.clip(lower=0)
85+
loss = -delta.clip(upper=0)
86+
avg_gain = gain.rolling(window=14).mean()
87+
avg_loss = loss.rolling(window=14).mean()
88+
rs = avg_gain / (avg_loss + 1e-9)
89+
rsi = 100 - (100 / (1 + rs))
90+
91+
return {
92+
"ema": float(ema.iloc[-1]),
93+
"macd": float(macd.iloc[-1]),
94+
"signal": float(signal.iloc[-1]),
95+
"rsi": float(rsi.iloc[-1]),
96+
}
97+
98+
def fetch_latest(self) -> MarketSnapshot:
99+
"""Return the newest snapshot and update internal state."""
100+
101+
self._append_new_row()
102+
history = self._history.copy()
103+
indicators = self._compute_indicators(history)
104+
latest = history.iloc[-1]
105+
return MarketSnapshot(
106+
symbol=self.symbol,
107+
price=float(latest["close"]),
108+
ema=indicators["ema"],
109+
macd=indicators["macd"],
110+
macd_signal=indicators["signal"],
111+
rsi=indicators["rsi"],
112+
volume=float(latest["volume"]),
113+
history=history,
114+
)
115+
116+
117+
__all__ = [
118+
"AccountState",
119+
"MarketSnapshot",
120+
"MockMarketDataFetcher",
121+
]

0 commit comments

Comments
 (0)