danghungithp's picture
Create app.py
967420b verified
import gradio as gr
import pandas as pd
import numpy as np
from vnstock import Screener, Trading, Quote
import datetime
# --- KHỞI TẠO VÀ TẢI DỮ LIỆU CƠ SỞ (BASE DATA) ---
# Tải dữ liệu Screener
try:
# Sử dụng Screener để tải một lượng lớn dữ liệu cơ bản
# Dữ liệu này chứa các chỉ số tài chính và thị trường quan trọng
print("Đang tải dữ liệu Screener từ TCBS (quá trình có thể mất 10-30 giây)...")
screener = Screener()
# Lấy danh sách tất cả các mã trên HOSE, HNX và UPCOM
screener_df = screener.stock(params={"exchangeName": "HOSE,HNX,UPCOM"}, limit=50000)
# Loại bỏ các cột không cần thiết hoặc chứa quá nhiều NaN
cols_to_keep = [
'ticker', 'exchange', 'industry', 'market_cap', 'roe',
'profit_last_4q', 'last_quarter_revenue_growth', 'last_quarter_profit_growth',
'second_quarter_revenue_growth', 'second_quarter_profit_growth'
]
screener_df = screener_df[cols_to_keep].copy()
# Xử lý NaN: Thay thế NaN bằng 0 trong các cột tăng trưởng để lọc dễ dàng hơn
screener_df[['roe', 'profit_last_4q', 'last_quarter_revenue_growth',
'last_quarter_profit_growth', 'second_quarter_revenue_growth',
'second_quarter_profit_growth']] = \
screener_df[['roe', 'profit_last_4q', 'last_quarter_revenue_growth',
'last_quarter_profit_growth', 'second_quarter_revenue_growth',
'second_quarter_profit_growth']].fillna(0)
print(f"Đã tải thành công {len(screener_df)} mã cổ phiếu.")
except Exception as e:
print(f"Lỗi khi tải dữ liệu cơ sở: {e}")
screener_df = pd.DataFrame() # Tạo DataFrame rỗng nếu lỗi
# --- CÁC HÀM PHÂN TÍCH THEO PHƯƠNG PHÁP MINERVINU ---
def minervini_fundamental_filter(df, min_roe, min_profit_growth_q1, min_profit_growth_q2):
"""
Lọc cơ bản (Fundamental Filter) theo tiêu chí tăng trưởng của Minervini:
1. Lợi nhuận ròng 4 quý gần nhất phải dương.
2. ROE cao (đầu vào của người dùng).
3. Tăng trưởng lợi nhuận quý gần nhất (Q1) phải mạnh.
4. Tăng trưởng lợi nhuận quý trước đó (Q2) nên là một số dương để thể hiện sự tăng tốc/tăng trưởng ổn định.
"""
if df.empty:
return pd.DataFrame(), "Lỗi: Không có dữ liệu cơ sở."
# Tiêu chí 1: Lợi nhuận 4 quý gần nhất phải dương
df_filtered = df[df['profit_last_4q'] > 0]
# Tiêu chí 2: ROE tối thiểu
df_filtered = df_filtered[df_filtered['roe'] >= min_roe]
# Tiêu chí 3 & 4: Tăng trưởng lợi nhuận quý gần nhất (Q1) và quý trước đó (Q2)
# Minervini thích tăng trưởng > 25% (hoặc thậm chí 40%+)
df_filtered = df_filtered[df_filtered['last_quarter_profit_growth'] >= min_profit_growth_q1]
df_filtered = df_filtered[df_filtered['second_quarter_profit_growth'] >= min_profit_growth_q2]
# Sắp xếp theo tăng trưởng lợi nhuận quý gần nhất (EPS Acceleration)
df_filtered = df_filtered.sort_values(by='last_quarter_profit_growth', ascending=False)
message = f"Đã lọc Cơ bản: {len(df_filtered)} mã đạt tiêu chí Tăng trưởng."
return df_filtered, message
def check_stage_2_and_vcp_proxy(ticker_list, days_back=250):
"""
Kiểm tra tiêu chí Kỹ thuật (Technical Check):
1. Xác nhận xu hướng Giai đoạn 2 (Stage 2 Uptrend).
2. Proxy cho VCP (Volatility Contraction Pattern) bằng cách kiểm tra độ biến động giá gần đây.
"""
# Khởi tạo đối tượng Trading để lấy dữ liệu lịch sử
results = []
for i, ticker in enumerate(ticker_list):
if i >= 30: # Giới hạn số lượng check lịch sử để tránh quá tải API và thời gian chờ
print(f"Đã dừng kiểm tra kỹ thuật sau 30 mã. Vui lòng tinh chỉnh lọc cơ bản.")
break
try:
# Lấy dữ liệu lịch sử giá
end_date = datetime.date.today().strftime('%Y-%m-%d')
start_date = (datetime.date.today() - datetime.timedelta(days=days_back)).strftime('%Y-%m-%d')
# Sử dụng data_source='TCBS' để đảm bảo tính nhất quán với Screener
# Sử dụng Quote để lấy dữ liệu lịch sử
quote = Quote()
history = quote.history(symbol=ticker, start_date=start_date, end_date=end_date)
history['Close'] = pd.to_numeric(history['close'])
# 1. Tính toán Moving Average
history['MA_20'] = history['Close'].rolling(window=20).mean()
history['MA_50'] = history['Close'].rolling(window=50).mean()
history['MA_200'] = history['Close'].rolling(window=200).mean()
if history.empty or history['MA_200'].iloc[-1] == 0: # Đảm bảo có đủ dữ liệu
continue
latest = history.iloc[-1]
# --- Kiểm tra Giai đoạn 2 (Stage 2 Uptrend) ---
# Tiêu chí cơ bản: Giá > MA50 > MA200 và các MA dốc lên
is_stage_2 = (
latest['Close'] > latest['MA_50'] and
latest['MA_50'] > latest['MA_200'] and
history['MA_50'].iloc[-1] > history['MA_50'].iloc[-20] # MA50 dốc lên (20 ngày trước)
)
# --- Kiểm tra Proxy VCP (Biến động thấp gần đây) ---
# Tính biên độ giao dịch (High - Low) trong 20 ngày gần nhất
history['Range'] = (history['high'] - history['low']) / history['close']
# Biến động trung bình trong 20 ngày gần nhất
recent_volatility = history['Range'].iloc[-20:].mean()
# Biến động trung bình trong 100 ngày trước đó
past_volatility = history['Range'].iloc[-120:-20].mean()
# Proxy VCP: Biến động gần đây nhỏ hơn đáng kể so với quá khứ (Ví dụ: nhỏ hơn 40%)
vcp_proxy_check = (recent_volatility < past_volatility * 0.6) if past_volatility else False
results.append({
'ticker': ticker,
'Stage_2': "Đạt" if is_stage_2 else "Không",
'VCP_Proxy_Score': recent_volatility * 100, # Lưu giữ dạng số
'Meets_VCP_Proxy': "Đạt" if vcp_proxy_check else "Không",
'Close': float(latest['Close']),
'MA_20': float(latest['MA_20']),
'MA_50': float(latest['MA_50']),
'MA_200': float(latest['MA_200'])
})
except Exception as e:
# print(f"Lỗi khi xử lý {ticker}: {e}")
continue # Bỏ qua mã lỗi
return pd.DataFrame(results)
# --- HÀM CHÍNH CHO GRADIO ---
def calculate_fundamental_score(df):
"""Tính điểm đánh giá cho tiêu chí tài chính SEPA"""
# Điểm SEPA (tối đa 100 điểm)
df['SEPA_Score'] = 0
# ROE (30 điểm)
df['SEPA_Score'] += (df['roe'] / 50 * 30).clip(0, 30)
# Tăng trưởng lãi Q1 (40 điểm)
df['SEPA_Score'] += (df['last_quarter_profit_growth'] / 100 * 40).clip(0, 40)
# Tăng trưởng lãi Q2 (30 điểm)
df['SEPA_Score'] += (df['second_quarter_profit_growth'] / 100 * 30).clip(0, 30)
return df
def calculate_technical_score(df):
"""Tính điểm đánh giá cho tiêu chí kỹ thuật VCP"""
# Điểm VCP (tối đa 100 điểm)
df['VCP_Score'] = 0
# Stage 2 (50 điểm)
df['VCP_Score'] += np.where(df['Stage_2'] == 'Đạt', 50, 0)
# VCP Pattern (50 điểm)
df['VCP_Score'] += np.where(df['Meets_VCP_Proxy'] == 'Đạt', 50, 0)
return df
def calculate_total_score(df):
"""Tính điểm tổng hợp từ điểm SEPA và VCP"""
if 'SEPA_Score' in df.columns and 'VCP_Score' in df.columns:
df['Total_Score'] = (df['SEPA_Score'] + df['VCP_Score']) / 2
return df
def format_dataframe(df, output_type='fundamental'):
"""Hàm định dạng DataFrame để hiển thị"""
if df.empty:
return pd.DataFrame()
result_df = df.copy()
def safe_get_columns(df, columns):
"""Lấy các cột an toàn, bỏ qua các cột không tồn tại"""
available_cols = [col for col in columns if col in df.columns]
return df[available_cols]
if output_type == 'fundamental':
# Định dạng cho bảng kết quả tài chính
columns = [
'ticker', 'industry', 'roe',
'profit_last_4q', 'last_quarter_profit_growth',
'second_quarter_profit_growth', 'market_cap', 'SEPA_Score'
]
df = safe_get_columns(df, columns).copy()
# Đổi tên cột
column_mapping = {
'ticker': 'Mã CK',
'industry': 'Ngành',
'roe': 'ROE (%)',
'profit_last_4q': 'Lãi 4Q (tỷ)',
'last_quarter_profit_growth': 'Tăng trưởng Q1 (%)',
'second_quarter_profit_growth': 'Tăng trưởng Q2 (%)',
'market_cap': 'Vốn hóa (tỷ)',
'SEPA_Score': 'Điểm SEPA'
}
df = df.rename(columns=column_mapping)
# Làm tròn và định dạng số
if 'ROE (%)' in df.columns:
df['ROE (%)'] = df['ROE (%)'].round(1)
if 'Tăng trưởng Q1 (%)' in df.columns:
df['Tăng trưởng Q1 (%)'] = df['Tăng trưởng Q1 (%)'].round(1)
if 'Tăng trưởng Q2 (%)' in df.columns:
df['Tăng trưởng Q2 (%)'] = df['Tăng trưởng Q2 (%)'].round(1)
if 'Lãi 4Q (tỷ)' in df.columns:
df['Lãi 4Q (tỷ)'] = (df['Lãi 4Q (tỷ)'] / 1e9).round(0)
if 'Vốn hóa (tỷ)' in df.columns:
df['Vốn hóa (tỷ)'] = (df['Vốn hóa (tỷ)'] / 1e9).round(0)
if 'Điểm SEPA' in df.columns:
df['Điểm SEPA'] = df['Điểm SEPA'].round(2)
elif output_type == 'technical':
# Định dạng cho bảng kết quả kỹ thuật
columns = [
'ticker', 'Close', 'MA_20', 'MA_50', 'MA_200',
'Stage_2', 'Meets_VCP_Proxy', 'VCP_Proxy_Score', 'VCP_Score'
]
df = safe_get_columns(df, columns).copy()
# Đổi tên cột
column_mapping = {
'ticker': 'Mã CK',
'Close': 'Giá',
'MA_20': 'MA 20',
'MA_50': 'MA 50',
'MA_200': 'MA 200',
'Stage_2': 'GĐ 2',
'Meets_VCP_Proxy': 'Proxy VCP',
'VCP_Proxy_Score': 'Độ biến động (%)',
'VCP_Score': 'Điểm KT'
}
df = df.rename(columns=column_mapping)
# Làm tròn các cột số
numeric_columns = ['Giá', 'MA 20', 'MA 50', 'MA 200', 'Độ biến động (%)', 'Điểm KT']
for col in numeric_columns:
if col in df.columns:
df[col] = df[col].round(2)
else: # output_type == 'final'
# Định dạng cho bảng kết quả tổng hợp
columns = [
'ticker', 'industry', 'Close',
'SEPA_Score', 'VCP_Score', 'Total_Score'
]
df = safe_get_columns(df, columns).copy()
# Đổi tên cột
column_mapping = {
'ticker': 'Mã CK',
'industry': 'Ngành',
'Close': 'Giá',
'SEPA_Score': 'Điểm SEPA',
'VCP_Score': 'Điểm KT',
'Total_Score': 'Điểm Tổng hợp'
}
df = df.rename(columns=column_mapping)
# Làm tròn các cột số
numeric_columns = ['Giá', 'Điểm SEPA', 'Điểm KT', 'Điểm Tổng hợp']
for col in numeric_columns:
if col in df.columns:
df[col] = df[col].round(2)
return df
def minervini_screener_app(min_roe, min_profit_q1, min_profit_q2):
"""
Hàm chính điều phối toàn bộ quá trình lọc và phân tích
"""
if screener_df.empty:
return "Lỗi: Không thể tải dữ liệu cơ sở. Vui lòng kiểm tra kết nối API.", None, None, None
# Bước 1: Lọc Cơ bản
df_fundamental, message_fund = minervini_fundamental_filter(
screener_df, min_roe, min_profit_q1, min_profit_q2
)
if df_fundamental.empty:
return message_fund, pd.DataFrame(), pd.DataFrame(), pd.DataFrame()
# Bước 2: Kiểm tra Kỹ thuật
ticker_list = df_fundamental['ticker'].tolist()
# Tính điểm SEPA cho kết quả lọc cơ bản
df_fundamental = calculate_fundamental_score(df_fundamental)
# Kiểm tra Kỹ thuật (chỉ áp dụng cho các mã đã qua lọc Cơ bản)
df_technical = check_stage_2_and_vcp_proxy(ticker_list)
if df_technical.empty:
return (f"{message_fund} | Không có mã nào vượt qua kiểm tra Kỹ thuật.",
format_dataframe(df_fundamental, 'fundamental'),
pd.DataFrame(),
pd.DataFrame())
# Tính điểm VCP cho kết quả lọc kỹ thuật
df_technical = calculate_technical_score(df_technical)
# Bước 3: Tổng hợp kết quả
df_result = pd.merge(df_fundamental, df_technical, on='ticker', how='inner')
# Tính điểm tổng hợp
df_result = calculate_total_score(df_result)
# Lọc cuối cùng: Chỉ giữ lại các mã Đạt cả Stage 2 và Proxy VCP
df_final = df_result.copy()
if 'Stage_2' in df_result.columns and 'Meets_VCP_Proxy' in df_result.columns:
df_final = df_result[
(df_result['Stage_2'] == 'Đạt') & (df_result['Meets_VCP_Proxy'] == 'Đạt')
]
# Chuẩn bị kết quả cho từng bảng
df_fundamental_display = format_dataframe(df_fundamental, 'fundamental')
df_technical_display = format_dataframe(df_technical, 'technical')
df_final_display = format_dataframe(df_final, 'final')
final_message = f"Kết quả Lọc Tổng hợp:\n"
final_message += f"- Cổ phiếu đạt tiêu chí Tài chính: {len(df_fundamental)} mã\n"
final_message += f"- Cổ phiếu đạt tiêu chí Kỹ thuật: {len(df_technical)} mã\n"
final_message += f"- Cổ phiếu Đạt cả hai tiêu chí: {len(df_final)} mã"
return final_message, df_fundamental_display, df_technical_display, df_final_display
# --- XÂY DỰNG GIAO DIỆN GRADIO ---
# Cài đặt theme và tiêu đề
with gr.Blocks(title="Minervini SEPA/VCP Screener") as demo:
with gr.Column():
gr.Markdown(
"""
# 📈 Công cụ Lọc Cổ phiếu theo phương pháp Minervini (SEPA/VCP)
Sử dụng dữ liệu tài chính và giá từ Vnstock để áp dụng các tiêu chí lọc.
### Hướng dẫn sử dụng:
1. **Tiêu chí SEPA** (Điểm SEPA):
- ROE (30 điểm)
- Tăng trưởng Lãi Q1 (40 điểm)
- Tăng trưởng Lãi Q2 (30 điểm)
2. **Tiêu chí VCP** (Điểm KT):
- Stage 2: Giá > MA50 > MA200 (50 điểm)
- VCP Pattern: Biến động giám (50 điểm)
3. **Điểm tổng hợp** = (SEPA + VCP) / 2
"""
)
# Khu vực điều khiển
with gr.Row():
min_roe = gr.Slider(
minimum=0, maximum=50, step=1, value=15, label="ROE Tối thiểu (%)"
)
min_profit_q1 = gr.Slider(
minimum=0, maximum=100, step=5, value=25, label="Tăng trưởng Lãi ròng Q1 Tối thiểu (%)"
)
min_profit_q2 = gr.Slider(
minimum=0, maximum=100, step=5, value=0, label="Tăng trưởng Lãi ròng Q2 Tối thiểu (%)"
)
# Nút bấm và thông báo
with gr.Row():
btn_screen = gr.Button("🚀 Bắt đầu Lọc Cổ phiếu", variant="primary", scale=2)
with gr.Row():
status_message = gr.Textbox(label="Trạng thái Lọc", placeholder="Bấm nút để bắt đầu...", lines=4)
# Phần tabs kết quả
with gr.Tabs():
with gr.TabItem("Kết quả Tài chính (SEPA)"):
fundamental_df = gr.DataFrame(
label="Cổ phiếu Đạt tiêu chí Tài chính",
interactive=False,
row_count=10
)
with gr.TabItem("Kết quả Kỹ thuật (VCP)"):
technical_df = gr.DataFrame(
label="Cổ phiếu Đạt tiêu chí Kỹ thuật",
interactive=False,
row_count=10
)
with gr.TabItem("Kết quả Tổng hợp"):
final_df = gr.DataFrame(
label="Cổ phiếu Đạt Tất cả Tiêu chí",
interactive=False,
row_count=10
)
# Gắn hàm xử lý với nút bấm
btn_screen.click(
minervini_screener_app,
inputs=[min_roe, min_profit_q1, min_profit_q2],
outputs=[status_message, fundamental_df, technical_df, final_df]
)
# Chạy ứng dụng Gradio
demo.launch(share=True)