|
|
import gradio as gr |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
from vnstock import Screener, Trading, Quote |
|
|
import datetime |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
print("Đang tải dữ liệu Screener từ TCBS (quá trình có thể mất 10-30 giây)...") |
|
|
screener = Screener() |
|
|
|
|
|
screener_df = screener.stock(params={"exchangeName": "HOSE,HNX,UPCOM"}, limit=50000) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
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ở." |
|
|
|
|
|
|
|
|
df_filtered = df[df['profit_last_4q'] > 0] |
|
|
|
|
|
|
|
|
df_filtered = df_filtered[df_filtered['roe'] >= min_roe] |
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
results = [] |
|
|
|
|
|
for i, ticker in enumerate(ticker_list): |
|
|
if i >= 30: |
|
|
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: |
|
|
|
|
|
end_date = datetime.date.today().strftime('%Y-%m-%d') |
|
|
start_date = (datetime.date.today() - datetime.timedelta(days=days_back)).strftime('%Y-%m-%d') |
|
|
|
|
|
|
|
|
|
|
|
quote = Quote() |
|
|
history = quote.history(symbol=ticker, start_date=start_date, end_date=end_date) |
|
|
history['Close'] = pd.to_numeric(history['close']) |
|
|
|
|
|
|
|
|
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: |
|
|
continue |
|
|
|
|
|
latest = history.iloc[-1] |
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
history['Range'] = (history['high'] - history['low']) / history['close'] |
|
|
|
|
|
|
|
|
recent_volatility = history['Range'].iloc[-20:].mean() |
|
|
|
|
|
|
|
|
past_volatility = history['Range'].iloc[-120:-20].mean() |
|
|
|
|
|
|
|
|
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, |
|
|
'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: |
|
|
|
|
|
continue |
|
|
|
|
|
return pd.DataFrame(results) |
|
|
|
|
|
|
|
|
|
|
|
def calculate_fundamental_score(df): |
|
|
"""Tính điểm đánh giá cho tiêu chí tài chính SEPA""" |
|
|
|
|
|
df['SEPA_Score'] = 0 |
|
|
|
|
|
df['SEPA_Score'] += (df['roe'] / 50 * 30).clip(0, 30) |
|
|
|
|
|
df['SEPA_Score'] += (df['last_quarter_profit_growth'] / 100 * 40).clip(0, 40) |
|
|
|
|
|
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""" |
|
|
|
|
|
df['VCP_Score'] = 0 |
|
|
|
|
|
df['VCP_Score'] += np.where(df['Stage_2'] == 'Đạt', 50, 0) |
|
|
|
|
|
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': |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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': |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
columns = [ |
|
|
'ticker', 'industry', 'Close', |
|
|
'SEPA_Score', 'VCP_Score', 'Total_Score' |
|
|
] |
|
|
df = safe_get_columns(df, columns).copy() |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
ticker_list = df_fundamental['ticker'].tolist() |
|
|
|
|
|
|
|
|
df_fundamental = calculate_fundamental_score(df_fundamental) |
|
|
|
|
|
|
|
|
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()) |
|
|
|
|
|
|
|
|
df_technical = calculate_technical_score(df_technical) |
|
|
|
|
|
|
|
|
df_result = pd.merge(df_fundamental, df_technical, on='ticker', how='inner') |
|
|
|
|
|
|
|
|
df_result = calculate_total_score(df_result) |
|
|
|
|
|
|
|
|
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') |
|
|
] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
""" |
|
|
) |
|
|
|
|
|
|
|
|
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 (%)" |
|
|
) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
btn_screen.click( |
|
|
minervini_screener_app, |
|
|
inputs=[min_roe, min_profit_q1, min_profit_q2], |
|
|
outputs=[status_message, fundamental_df, technical_df, final_df] |
|
|
) |
|
|
|
|
|
|
|
|
demo.launch(share=True) |
|
|
|