Chuyển tới nội dung chính

🐍 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 html và dcc component.
  • 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íFlaskDjangoStreamlitDash
Kiểm soát giao diệnCaoCaoThấpRất cao
Tốc độ viết codeTrung bìnhChậmRất nhanhTrung bình
Dashboard nhiều chartKhóKhóTốtXuất sắc
Callback liên kếtTự viết JSTự viết JSRerun toàn scriptChuẩn xác từng phần
Learning curveThấpCaoRất thấpTrung bình
Phù hợpWeb app tổng quátCMS, ERPPrototypeDashboard 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ành app.run(). Nhiều tutorial cũ vẫn dùng tên cũ và sẽ báo DeprecationWarning.


🧱 Bước 2: Layout — html và dcc​

Dash dùng hai họ component:

ModuleChứa gìVí dụ
dash.htmlThẻ HTMLDiv, H1, P, Table, Button
dash.dccComponent tương tácGraph, Dropdown, Slider, Store, Tabs
dash.dash_tableBảng dữ liệu nâng caoDataTable (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ính figure của component id="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ệmVai trò
OutputNơi ghi kết quả (phải có duy nhất một Output chính)
InputKích hoạt callback khi thay đổi
StateĐọc giá trị không kích hoạt callback
PreventUpdateKhô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ùngKhi nào
InputCần callback chạy lại ngay khi giá trị đổi
StateChỉ 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 ở đâuKhi nào dùng
"memory" (mặc định)RAM trình duyệtNhanh nhất, mất khi F5
"session"sessionStorageGiữ khi F5, mất khi đóng tab
"local"localStorageGiữ 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ảngGhi chú
Render / RailwayStart command: gunicorn app:server
VPS + nginxReverse proxy + HTTPS
Hugging Face SpacesChọn SDK "Docker", expose port 8050

Checklist production:

  • debug=False
  • Dùng app.server (biến WSGI), không dùng app.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​

  1. Thêm biểu đồ RSI 14 vào dashboard với vùng tô đỏ/xanh.
  2. Thêm dcc.Interval để dữ liệu tự cập nhật mỗi 60 giây.
  3. Dùng dash_table.DataTable hiển thị 50 phiên gần nhất, có sắp xếp và lọc.
  4. Cho phép so sánh 2 cặp cùng lúc bằng dcc.Dropdown(multi=True).
  5. Thêm tab "Hướng dẫn" bằng dcc.Tabs.
  6. Deploy dashboard lên Render và lấy link công khai.

⚠️ Lỗi thường gặp​

LỗiNguyên nhânCách sửa
Duplicate callback outputsHai callback cùng ghi một OutputGộp lại hoặc dùng allow_duplicate=True
A nonexistent object was used in Outputid không tồn tại trong layoutKiểm tra chính tả id
Callback chạy ngay khi tải trangThiếu prevent_initial_callThêm prevent_initial_call=True
app.run_server deprecatedDùng API cũĐổi sang app.run()
Callback lồng trong callbackVi phạm quy tắc DashDùng dcc.Store + chuỗi callback
Chart không cập nhậtQuên return figĐảm bảo callback có return
Trang trắngapp.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.Store làm cầu nối.
  • dcc.Interval cho 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ạnNội dungNgày
Nền tảngCú pháp, kiểu dữ liệu, luồng điều khiển1–15
Cấu trúc dữ liệu & hàmList, dict, hàm, module16–30
Xử lý dữ liệuPandas, API, file31–43
Đưa lên webBot, Flask, Django, FastAPI, Streamlit, Gradio, Dash44–50

Bạn đã có đủ công cụ để đưa bất kỳ script Python nào lên internet. 🎉


📚 Học tiếp​