🐍 Ngày 50 Python 365 ngày | Xây dựng dashboard với Plotly Dash
🗓 Ngày 50: Cột mốc 50 ngày — dashboard chuyên nghiệp với Dash
Chúc mừng bạn đã đi được 50 ngày! Hôm nay chúng ta khép lại chuỗi bài về web bằng Plotly Dash — framework mạnh nhất trong nhóm khi cần dashboard nhiều biểu đồ, nhiều bộ lọc liên kết và hiệu năng cao.
Nếu Streamlit ưu tiên tốc độ viết code và Gradio ưu tiên demo AI, thì Dash ưu tiên kiểm soát và quy mô: bạn điều khiển từng pixel, từng callback.
🎯 Mục tiêu bài học
- Hiểu kiến trúc callback của Dash.
- Xây layout bằng
htmlvàdcccomponent. - Tạo interactive callback với
Input/Output/State. - Vẽ nhiều loại biểu đồ Plotly trên cùng dashboard.
- Dùng
dcc.Stoređể chia sẻ dữ liệu giữa các callback. - Xây dashboard tài chính hoàn chỉnh.
- Deploy lên production.
🆚 So sánh 4 framework web Python
| Tiêu chí | Flask | Django | Streamlit | Dash |
|---|---|---|---|---|
| Kiểm soát giao diện | Cao | Cao | Thấp | Rất cao |
| Tốc độ viết code | Trung bình | Chậm | Rất nhanh | Trung bình |
| Dashboard nhiều chart | Khó | Khó | Tốt | Xuất sắc |
| Callback liên kết | Tự viết JS | Tự viết JS | Rerun toàn script | Chuẩn xác từng phần |
| Learning curve | Thấp | Cao | Rất thấp | Trung bình |
| Phù hợp | Web app tổng quát | CMS, ERP | Prototype | Dashboard lớn |
💡 Chọn nhanh: Cần dashboard 5–20 biểu đồ liên kết nhau, nhiều người dùng, deploy lâu dài → Dash. Cần prototype trong 30 phút → Streamlit.
🧰 Cài đặt
python -m venv .venv
.venv\Scripts\activate
pip install dash pandas plotly yfinance
python app.py
Mở http://127.0.0.1:8050.
🚀 Bước 1: App nhỏ nhất
# app.py
from dash import Dash, html
app = Dash(__name__)
app.layout = html.Div([
html.H1("📈 Dashboard Python 365 ngày"),
html.P("Ngày 50 — Plotly Dash"),
])
if __name__ == "__main__":
app.run(debug=True)
⚠️ Lưu ý về version: từ Dash 2.0,
app.run_server()đã đổi thànhapp.run(). Nhiều tutorial cũ vẫn dùng tên cũ và sẽ báoDeprecationWarning.
🧱 Bước 2: Layout — html và dcc
Dash dùng hai họ component:
| Module | Chứa gì | Ví dụ |
|---|---|---|
dash.html | Thẻ HTML | Div, H1, P, Table, Button |
dash.dcc | Component tương tác | Graph, Dropdown, Slider, Store, Tabs |
dash.dash_table | Bảng dữ liệu nâng cao | DataTable (sort, filter, phân trang) |
Cú pháp giống HTML nhưng viết bằng Python:
from dash import Dash, html, dcc
app = Dash(__name__)
app.layout = html.Div(
style={"fontFamily": "system-ui, sans-serif", "maxWidth": "1400px", "margin": "0 auto"},
children=[
html.H1("📈 Dashboard Crypto", style={"textAlign": "center"}),
html.Div(
style={"display": "grid", "gridTemplateColumns": "260px 1fr", "gap": "20px"},
children=[
# --- Cột điều khiển ---
html.Div(
style={"background": "#f8fafc", "padding": "16px", "borderRadius": "12px"},
children=[
html.H3("⚙️ Bộ lọc"),
html.Label("Cặp giao dịch"),
dcc.Dropdown(
id="chon-symbol",
options=[{"label": s, "value": s}
for s in ["BTC-USD", "ETH-USD", "SOL-USD"]],
value="BTC-USD",
clearable=False,
),
html.Br(),
html.Label("Số ngày"),
dcc.Slider(id="chon-so-ngay", min=30, max=365, step=30, value=90,
marks={30: "30", 90: "90", 180: "180", 365: "365"}),
html.Br(),
html.Label("Loại biểu đồ"),
dcc.RadioItems(
id="chon-loai-chart",
options=[{"label": " Nến", "value": "candle"},
{"label": " Đường", "value": "line"}],
value="candle",
),
],
),
# --- Cột biểu đồ ---
html.Div([
dcc.Graph(id="bieu-do-gia"),
dcc.Graph(id="bieu-do-khoi-luong"),
]),
],
),
],
)
Quy tắc quan trọng: mọi component cần tương tác phải có id — đó là cách callback tìm thấy nó.
🔗 Bước 3: Callback — trái tim của Dash
Callback là hàm Python tự chạy khi input thay đổi.
from dash import Input, Output
import plotly.graph_objects as go
import yfinance as yf
def lay_du_lieu(symbol: str, so_ngay: int):
df = yf.download(symbol, period=f"{so_ngay}d", interval="1d", progress=False)
if df.empty:
return df
df.columns = [c[0] if isinstance(c, tuple) else c for c in df.columns]
return df
@app.callback(
Output("bieu-do-gia", "figure"),
Input("chon-symbol", "value"),
Input("chon-so-ngay", "value"),
Input("chon-loai-chart", "value"),
)
def cap_nhat_bieu_do_gia(symbol, so_ngay, loai_chart):
df = lay_du_lieu(symbol, so_ngay)
fig = go.Figure()
if df.empty:
fig.add_annotation(text="Không có dữ liệu", showarrow=False)
elif loai_chart == "candle":
fig.add_trace(go.Candlestick(
x=df.index, open=df["Open"], high=df["High"],
low=df["Low"], close=df["Close"], name=symbol,
))
fig.update_layout(xaxis_rangeslider_visible=False)
else:
fig.add_trace(go.Scatter(x=df.index, y=df["Close"], name=symbol, mode="lines"))
fig.update_layout(
title=f"{symbol} — {so_ngay} ngày",
template="plotly_white",
height=420,
margin=dict(l=40, r=20, t=50, b=30),
)
return fig
Đọc callback này:
Output("bieu-do-gia", "figure")→ ghi vào thuộc tínhfigurecủa componentid="bieu-do-gia".- Ba
Input→ hàm nhận đúng ba tham số theo thứ tự. - Dash tự gọi lại hàm này mỗi khi bất kỳ input nào đổi.
| Khái niệm | Vai trò |
|---|---|
Output | Nơi ghi kết quả (phải có duy nhất một Output chính) |
Input | Kích hoạt callback khi thay đổi |
State | Đọc giá trị không kích hoạt callback |
PreventUpdate | Không cập nhật gì (dùng trong điều kiện) |
🎛️ Bước 4: State — đọc mà không kích hoạt
from dash import Input, Output, State
@app.callback(
Output("bang-du-lieu", "data"),
Input("nut-tai", "n_clicks"), # chỉ chạy khi BẤM NÚT
State("chon-symbol", "value"), # đọc giá trị hiện tại
State("chon-so-ngay", "value"),
prevent_initial_call=True,
)
def tai_du_lieu_khi_bam(n_clicks, symbol, so_ngay):
if not n_clicks:
return []
df = lay_du_lieu(symbol, so_ngay)
return df.reset_index().tail(50).to_dict("records")
So sánh:
| Dùng | Khi nào |
|---|---|
Input | Cần callback chạy lại ngay khi giá trị đổi |
State | Chỉ cần đọc giá trị vào thời điểm callback chạy (ví dụ: khi bấm nút) |
🗄️ Bước 5: dcc.Store — chia sẻ dữ liệu giữa các callback
Vấn đề thực tế: bạn không muốn tải dữ liệu 3 lần cho 3 biểu đồ.
# 1. Khai báo nơi lưu (trong layout)
dcc.Store(id="kho-du-lieu", storage_type="memory")
# 2. Callback 1: tải dữ liệu MỘT LẦN, lưu vào Store
@app.callback(
Output("kho-du-lieu", "data"),
Input("chon-symbol", "value"),
Input("chon-so-ngay", "value"),
)
def tai_va_luu(symbol, so_ngay):
df = lay_du_lieu(symbol, so_ngay)
if df.empty:
return {}
return {
"dates": df.index.strftime("%Y-%m-%d").tolist(),
"close": df["Close"].tolist(),
"volume": df["Volume"].tolist(),
}
# 3. Callback 2: vẽ giá từ Store
@app.callback(
Output("bieu-do-gia", "figure"),
Input("kho-du-lieu", "data"),
Input("chon-loai-chart", "value"),
prevent_initial_call=True,
)
def ve_gia(data, loai_chart):
if not data:
return go.Figure()
fig = go.Figure()
if loai_chart == "line":
fig.add_trace(go.Scatter(x=data["dates"], y=data["close"], mode="lines"))
else:
fig.add_trace(go.Bar(x=data["dates"], y=data["close"]))
return fig
# 4. Callback 3: vẽ khối lượng từ CÙNG Store — không tải lại
@app.callback(
Output("bieu-do-khoi-luong", "figure"),
Input("kho-du-lieu", "data"),
prevent_initial_call=True,
)
def ve_khoi_luong(data):
if not data:
return go.Figure()
return go.Figure(
data=[go.Bar(x=data["dates"], y=data["volume"], marker_color="#94a3b8")],
layout=go.Layout(title="Khối lượng", height=260,
margin=dict(l=40, r=20, t=40, b=30)),
)
Lưu ý về storage_type:
| Giá trị | Lưu ở đâu | Khi nào dùng |
|---|---|---|
"memory" (mặc định) | RAM trình duyệt | Nhanh nhất, mất khi F5 |
"session" | sessionStorage | Giữ khi F5, mất khi đóng tab |
"local" | localStorage | Giữ lâu dài |
📋 Bước 6: DataTable — bảng dữ liệu chuyên nghiệp
from dash import dash_table as dt
dt.DataTable(
id="bang-giao-dich",
columns=[
{"name": "Ngày", "id": "ngay"},
{"name": "Giá", "id": "gia", "type": "numeric", "format": {"specifier": ",.2f"}},
{"name": "Khối lượng", "id": "khoi_luong", "type": "numeric"},
],
data=[],
page_size=10,
sort_action="native",
filter_action="native",
style_table={"overflowX": "auto"},
style_cell={"textAlign": "left", "padding": "8px", "fontFamily": "system-ui"},
style_header={"backgroundColor": "#1e293b", "color": "white", "fontWeight": "bold"},
style_data_conditional=[
{"if": {"row_index": "odd"}, "backgroundColor": "#f8fafc"},
],
)
DataTable cho bạn sắp xếp, lọc, phân trang miễn phí — không cần viết JavaScript.
🗂️ Bước 7: Multi-page app (Dash 2.5+)
app.py
pages/
├── home.py
├── phan_tich.py
└── cai_dat.py
# app.py
from dash import Dash, html, dcc, page_container
app = Dash(__name__, use_pages=True)
app.layout = html.Div([
html.H1("📈 Crypto Suite"),
html.Nav([
dcc.Link("Trang chủ", href="/"),
" | ",
dcc.Link("Phân tích", href="/phan-tich"),
" | ",
dcc.Link("Cài đặt", href="/cai-dat"),
]),
html.Hr(),
page_container,
])
Mỗi file trong pages/ đăng ký bằng dash.register_page(__name__, path="/phan-tich"). Dash tự tạo routing.
🎨 Bước 8: Dashboard hoàn chỉnh + giao diện
# app.py — bản đầy đủ
import dash
from dash import Dash, html, dcc, Input, Output
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import yfinance as yf
import pandas as pd
external_stylesheets = ["https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"]
app = Dash(__name__, external_stylesheets=external_stylesheets,
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}])
server = app.server # ← dùng cho gunicorn khi deploy
def lay_du_lieu(symbol, so_ngay):
df = yf.download(symbol, period=f"{so_ngay}d", interval="1d", progress=False)
if df.empty:
return df
df.columns = [c[0] if isinstance(c, tuple) else c for c in df.columns]
df["SMA20"] = df["Close"].rolling(20).mean()
return df
app.layout = html.Div(style={"fontFamily": "Inter, sans-serif", "padding": "24px"}, children=[
html.H1("📈 Dashboard Phân tích Crypto"),
html.P("Python 365 ngày — Ngày 50"),
html.Div(style={"display": "grid", "gridTemplateColumns": "260px 1fr", "gap": "24px"}, children=[
html.Div([
html.Label("Cặp giao dịch", style={"fontWeight": 600}),
dcc.Dropdown(id="symbol",
options=[{"label": s, "value": s} for s in ["BTC-USD", "ETH-USD", "SOL-USD"]],
value="BTC-USD", clearable=False),
html.Br(),
html.Label("Số ngày", style={"fontWeight": 600}),
dcc.Slider(id="so-ngay", min=30, max=365, step=30, value=180,
marks={30: "30", 180: "180", 365: "365"}),
html.Br(),
dcc.Checklist(id="tuy-chon",
options=[{"label": " Hiện SMA20", "value": "sma"},
{"label": " Hiện khối lượng", "value": "vol"}],
value=["sma", "vol"]),
], style={"background": "#f8fafc", "padding": "16px", "borderRadius": "12px"}),
html.Div(id="khu-bieu-do"),
]),
])
@app.callback(Output("khu-bieu-do", "children"),
Input("symbol", "value"),
Input("so-ngay", "value"),
Input("tuy-chon", "value"))
def cap_nhat_khu_bieu_do(symbol, so_ngay, tuy_chon):
df = lay_du_lieu(symbol, so_ngay)
if df.empty:
return html.P("❌ Không lấy được dữ liệu.")
co_vol = "vol" in tuy_chon
co_sma = "sma" in tuy_chon
if co_vol:
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
row_heights=[0.72, 0.28], vertical_spacing=0.05)
fig.add_trace(go.Scatter(x=df.index, y=df["Close"], name="Giá", mode="lines"), row=1, col=1)
if co_sma:
fig.add_trace(go.Scatter(x=df.index, y=df["SMA20"], name="SMA20",
mode="lines", line=dict(color="orange")), row=1, col=1)
fig.add_trace(go.Bar(x=df.index, y=df["Volume"], name="Khối lượng",
marker_color="#94a3b8"), row=2, col=1)
fig.update_layout(height=620, template="plotly_white",
legend=dict(orientation="h", y=1.02))
else:
fig = go.Figure()
fig.add_trace(go.Scatter(x=df.index, y=df["Close"], name="Giá", mode="lines"))
if co_sma:
fig.add_trace(go.Scatter(x=df.index, y=df["SMA20"], name="SMA20",
mode="lines", line=dict(color="orange")))
fig.update_layout(height=620, template="plotly_white")
gia_cuoi = float(df["Close"].iloc[-1])
thay_doi = (gia_cuoi / float(df["Close"].iloc[0]) - 1) * 100
return html.Div([
html.Div([
html.Span(f"Giá: ${gia_cuoi:,.2f}", style={"fontSize": "22px", "fontWeight": 600}),
html.Span(f" ({thay_doi:+.2f}% / {so_ngay} ngày)",
style={"color": "#16a34a" if thay_doi >= 0 else "#dc2626", "marginLeft": "8px"}),
], style={"marginBottom": "12px"}),
dcc.Graph(figure=fig, config={"displayModeBar": False}),
])
if __name__ == "__main__":
app.run(debug=True)
🚢 Bước 9: Deploy production
pip freeze > requirements.txt
gunicorn app:server -w 4 -b 0.0.0.0:8050
| Nền tảng | Ghi chú |
|---|---|
| Render / Railway | Start command: gunicorn app:server |
| VPS + nginx | Reverse proxy + HTTPS |
| Hugging Face Spaces | Chọn SDK "Docker", expose port 8050 |
Checklist production:
-
debug=False - Dùng
app.server(biến WSGI), không dùngapp.run() - Biến môi trường cho mọi secret
- Cache dữ liệu (
flask-caching) để giảm tải API - Ghi log ra file
- Test responsive trên mobile
🧪 Bài tập thực hành
- Thêm biểu đồ RSI 14 vào dashboard với vùng tô đỏ/xanh.
- Thêm
dcc.Intervalđể dữ liệu tự cập nhật mỗi 60 giây. - Dùng
dash_table.DataTablehiển thị 50 phiên gần nhất, có sắp xếp và lọc. - Cho phép so sánh 2 cặp cùng lúc bằng
dcc.Dropdown(multi=True). - Thêm tab "Hướng dẫn" bằng
dcc.Tabs. - Deploy dashboard lên Render và lấy link công khai.
⚠️ Lỗi thường gặp
| Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
Duplicate callback outputs | Hai callback cùng ghi một Output | Gộp lại hoặc dùng allow_duplicate=True |
A nonexistent object was used in Output | id không tồn tại trong layout | Kiểm tra chính tả id |
| Callback chạy ngay khi tải trang | Thiếu prevent_initial_call | Thêm prevent_initial_call=True |
app.run_server deprecated | Dùng API cũ | Đổi sang app.run() |
| Callback lồng trong callback | Vi phạm quy tắc Dash | Dùng dcc.Store + chuỗi callback |
| Chart không cập nhật | Quên return fig | Đảm bảo callback có return |
| Trang trắng | app.layout bị gán đè | Chỉ gán app.layout một lần |
📝 Ghi chú
- Callback phải trả về đúng kiểu với
Output(figure →go.Figure, children → HTML/str, data → list/dict). - Tránh callback lồng nhau — đó là giới hạn cứng của Dash. Dùng
dcc.Storelàm cầu nối. dcc.Intervalcho phép dashboard tự cập nhật — nhưng đừng đặt quá ngắn gây tốn tài nguyên.- Với dữ liệu lớn, hãy cache và chỉ truyền vào client phần cần thiết (dữ liệu Store đi qua JSON nên có giới hạn).
- Dash Enterprise có thêm bảo mật/SSO; bản mã nguồn mở đủ dùng cho phần lớn nhu cầu.
🎯 Tóm tắt ngày 50
✅ Hiểu kiến trúc callback Input → hàm → Output
✅ Xây layout bằng html và dcc
✅ Phân biệt Input vs State
✅ Chia sẻ dữ liệu giữa callback bằng dcc.Store
✅ Dùng DataTable có sort/filter miễn phí
✅ Tạo multi-page app và deploy với gunicorn app:server
🏆 Nhìn lại cột mốc 50 ngày
| Giai đoạn | Nội dung | Ngày |
|---|---|---|
| Nền tảng | Cú pháp, kiểu dữ liệu, luồng điều khiển | 1–15 |
| Cấu trúc dữ liệu & hàm | List, dict, hàm, module | 16–30 |
| Xử lý dữ liệu | Pandas, API, file | 31–43 |
| Đưa lên web | Bot, Flask, Django, FastAPI, Streamlit, Gradio, Dash | 44–50 |
Bạn đã có đủ công cụ để đưa bất kỳ script Python nào lên internet. 🎉