Spaces:
Sleeping
Sleeping
pililover commited on
Commit ·
97b7267
1
Parent(s): e1e34c6
First commit
Browse files- src/app.py +109 -0
- src/auth.py +80 -0
- src/pages/__pycache__/page_account.cpython-311.pyc +0 -0
- src/pages/__pycache__/page_history.cpython-311.pyc +0 -0
- src/pages/__pycache__/page_report.cpython-311.pyc +0 -0
- src/pages/page_account.py +58 -0
- src/pages/page_history.py +44 -0
- src/pages/page_report.py +117 -0
- src/report_generator.py +261 -0
- src/requirements.txt +0 -0
- src/streamlit_app.py +0 -40
- src/utils.py +176 -0
src/app.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from utils import initialize_firebase, load_css
|
| 3 |
+
from auth import (
|
| 4 |
+
verify_firebase_token,
|
| 5 |
+
register_user_to_mongo,
|
| 6 |
+
get_user_profile,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
# ==== Cấu hình trang và tải CSS ====
|
| 10 |
+
st.set_page_config(page_title="Đăng nhập - Stock Insights", page_icon="🔮", layout="centered")
|
| 11 |
+
|
| 12 |
+
auth_fb = initialize_firebase()
|
| 13 |
+
|
| 14 |
+
# Khởi tạo page mặc định
|
| 15 |
+
if "page" not in st.session_state:
|
| 16 |
+
st.session_state.page = "report"
|
| 17 |
+
|
| 18 |
+
# ==== Giao diện Đăng nhập / Đăng ký ====
|
| 19 |
+
if "uid" not in st.session_state:
|
| 20 |
+
# Set layout về centered cho trang đăng nhập
|
| 21 |
+
|
| 22 |
+
st.markdown("<h1>Stock Insights 🔮</h1>", unsafe_allow_html=True)
|
| 23 |
+
st.markdown("<p class='auth-subheader'>Chào mừng! Vui lòng đăng nhập hoặc đăng ký.</p>", unsafe_allow_html=True)
|
| 24 |
+
load_css()
|
| 25 |
+
|
| 26 |
+
login_tab, register_tab = st.tabs(["✨ Đăng nhập", "📝 Đăng ký"])
|
| 27 |
+
|
| 28 |
+
# --- FORM ĐĂNG NHẬP ---
|
| 29 |
+
with login_tab:
|
| 30 |
+
email_login = st.text_input("Email", key="email_login", placeholder="you@example.com")
|
| 31 |
+
password_login = st.text_input("Mật khẩu", type="password", key="password_login", placeholder="••••••••")
|
| 32 |
+
st.markdown("<div style='margin-top: 1rem;'></div>", unsafe_allow_html=True)
|
| 33 |
+
if st.button("Đăng nhập", key="login_btn", use_container_width=True):
|
| 34 |
+
if not email_login or not password_login:
|
| 35 |
+
st.warning("Vui lòng nhập đầy đủ email và mật khẩu.")
|
| 36 |
+
else:
|
| 37 |
+
try:
|
| 38 |
+
user = auth_fb.sign_in_with_email_and_password(email_login, password_login)
|
| 39 |
+
id_token = user["idToken"]
|
| 40 |
+
info = verify_firebase_token(id_token)
|
| 41 |
+
if info:
|
| 42 |
+
st.session_state["uid"] = info["uid"]
|
| 43 |
+
st.session_state["user_email"] = info.get("email", "")
|
| 44 |
+
profile = get_user_profile(info["uid"])
|
| 45 |
+
st.session_state["user_name"] = profile.get("user_name", "") if profile else ""
|
| 46 |
+
st.rerun()
|
| 47 |
+
except Exception as e:
|
| 48 |
+
st.error("Sai email hoặc mật khẩu!")
|
| 49 |
+
|
| 50 |
+
# --- FORM ĐĂNG KÝ ---
|
| 51 |
+
with register_tab:
|
| 52 |
+
with st.form("registration_form", clear_on_submit=True):
|
| 53 |
+
user_name_reg = st.text_input("Tên người dùng", placeholder="Nguyen Van A")
|
| 54 |
+
email_reg = st.text_input("Email", placeholder="you@example.com")
|
| 55 |
+
password_reg = st.text_input("Mật khẩu", type="password", placeholder="••••••••")
|
| 56 |
+
password_confirm_reg = st.text_input("Nhập lại mật khẩu", type="password", placeholder="••••••••")
|
| 57 |
+
st.markdown("<div style='margin-top: 1rem;'></div>", unsafe_allow_html=True)
|
| 58 |
+
submitted = st.form_submit_button("Đăng ký", use_container_width=True)
|
| 59 |
+
|
| 60 |
+
if submitted:
|
| 61 |
+
# ... (Giữ nguyên logic đăng ký) ...
|
| 62 |
+
st.success("Đăng ký thành công! Giờ bạn có thể đăng nhập.")
|
| 63 |
+
|
| 64 |
+
# ==== Giao diện chính sau khi đăng nhập ====
|
| 65 |
+
else:
|
| 66 |
+
# --- Thanh điều hướng tùy chỉnh ---
|
| 67 |
+
st.markdown('<div class="nav-container">', unsafe_allow_html=True)
|
| 68 |
+
|
| 69 |
+
# Sử dụng st.columns để đặt các nút cạnh nhau
|
| 70 |
+
col1, col2, col3 = st.columns(3)
|
| 71 |
+
|
| 72 |
+
with col1:
|
| 73 |
+
is_active = st.session_state.page == "report"
|
| 74 |
+
if st.button("📊 Báo cáo", use_container_width=True, key="nav_report"):
|
| 75 |
+
st.session_state.page = "report"
|
| 76 |
+
# Reset trạng thái báo cáo cũ
|
| 77 |
+
st.session_state.pop("selected_report", None)
|
| 78 |
+
st.session_state["show_form"] = True
|
| 79 |
+
if is_active:
|
| 80 |
+
st.markdown('<style>button[data-testid="stButton-nav_report"] {border-color: #30cfd0; color: #ffffff; box-shadow: 0 0 15px rgba(48, 207, 208, 0.4);}</style>', unsafe_allow_html=True)
|
| 81 |
+
|
| 82 |
+
with col2:
|
| 83 |
+
is_active = st.session_state.page == "history"
|
| 84 |
+
st.button("📜 Lịch sử", use_container_width=True, on_click=lambda: st.session_state.update(page="history"),
|
| 85 |
+
type="secondary" if not is_active else "primary",
|
| 86 |
+
key="nav_history")
|
| 87 |
+
if is_active:
|
| 88 |
+
st.markdown('<style>button[data-testid="stButton-nav_history"] {border-color: #30cfd0; color: #ffffff; box-shadow: 0 0 15px rgba(48, 207, 208, 0.4);}</style>', unsafe_allow_html=True)
|
| 89 |
+
|
| 90 |
+
with col3:
|
| 91 |
+
is_active = st.session_state.page == "account"
|
| 92 |
+
st.button("⚙️ Tài khoản", use_container_width=True, on_click=lambda: st.session_state.update(page="account"),
|
| 93 |
+
type="secondary" if not is_active else "primary",
|
| 94 |
+
key="nav_account")
|
| 95 |
+
if is_active:
|
| 96 |
+
st.markdown('<style>button[data-testid="stButton-nav_account"] {border-color: #30cfd0; color: #ffffff; box-shadow: 0 0 15px rgba(48, 207, 208, 0.4);}</style>', unsafe_allow_html=True)
|
| 97 |
+
|
| 98 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 99 |
+
|
| 100 |
+
# --- Hiển thị nội dung trang tương ứng ---
|
| 101 |
+
if st.session_state.page == "report":
|
| 102 |
+
from pages.page_report import main as report_main
|
| 103 |
+
report_main()
|
| 104 |
+
elif st.session_state.page == "history":
|
| 105 |
+
from pages.page_history import main as history_main
|
| 106 |
+
history_main()
|
| 107 |
+
elif st.session_state.page == "account":
|
| 108 |
+
from pages.page_account import main as account_main
|
| 109 |
+
account_main()
|
src/auth.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
import firebase_admin
|
| 4 |
+
from firebase_admin import credentials, auth as firebase_auth
|
| 5 |
+
from pymongo import MongoClient
|
| 6 |
+
from bson.binary import Binary
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
# cred_path = os.getenv("FIREBASE_ADMIN_KEY")
|
| 12 |
+
# if not cred_path:
|
| 13 |
+
# raise ValueError("FIREBASE_ADMIN_KEY chưa được khai báo trong .env hoặc đường dẫn bị sai!")
|
| 14 |
+
# if not firebase_admin._apps:
|
| 15 |
+
# cred = credentials.Certificate(cred_path)
|
| 16 |
+
# firebase_admin.initialize_app(cred)
|
| 17 |
+
|
| 18 |
+
firebase_admin_json = os.getenv("FIREBASE_ADMIN_JSON")
|
| 19 |
+
if firebase_admin_json:
|
| 20 |
+
with open("serviceAccountKey.json", "w") as f:
|
| 21 |
+
f.write(firebase_admin_json)
|
| 22 |
+
cred_path = "serviceAccountKey.json"
|
| 23 |
+
else:
|
| 24 |
+
cred_path = os.getenv("FIREBASE_ADMIN_KEY")
|
| 25 |
+
|
| 26 |
+
if not cred_path:
|
| 27 |
+
raise ValueError("FIREBASE_ADMIN_KEY chưa được khai báo trong .env hoặc đường dẫn bị sai!")
|
| 28 |
+
if not firebase_admin._apps:
|
| 29 |
+
cred = credentials.Certificate(cred_path)
|
| 30 |
+
firebase_admin.initialize_app(cred)
|
| 31 |
+
|
| 32 |
+
mongo_uri = os.getenv("MONGO_URI")
|
| 33 |
+
mongo_dbname = os.getenv("MONGO_DBNAME")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_mongo_collection():
|
| 37 |
+
client = MongoClient(mongo_uri)
|
| 38 |
+
db = client[mongo_dbname]
|
| 39 |
+
return db["users"]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def verify_firebase_token(id_token):
|
| 43 |
+
try:
|
| 44 |
+
decoded = firebase_auth.verify_id_token(id_token)
|
| 45 |
+
return decoded
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print("Token verify fail:", e)
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def register_user_to_mongo(uid, email, user_name):
|
| 52 |
+
users = get_mongo_collection()
|
| 53 |
+
|
| 54 |
+
if not users.find_one({"uid": uid}):
|
| 55 |
+
print("Registering new user:", uid, email, user_name)
|
| 56 |
+
users.insert_one({"uid": uid, "email": email, "user_name": user_name})
|
| 57 |
+
return True
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def save_avatar(uid, file_bytes):
|
| 61 |
+
users = get_mongo_collection()
|
| 62 |
+
users.update_one(
|
| 63 |
+
{"uid": uid}, {"$set": {"avatar_blob": Binary(file_bytes)}}, upsert=True
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_avatar_blob(uid):
|
| 68 |
+
users = get_mongo_collection()
|
| 69 |
+
user = users.find_one({"uid": uid}, {"avatar_blob": 1})
|
| 70 |
+
return user.get("avatar_blob") if user and "avatar_blob" in user else None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def get_user_profile(uid):
|
| 74 |
+
users = get_mongo_collection()
|
| 75 |
+
return users.find_one({"uid": uid})
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def update_username_in_mongo(uid, new_username):
|
| 79 |
+
users = get_mongo_collection()
|
| 80 |
+
users.update_one({"uid": uid}, {"$set": {"user_name": new_username}})
|
src/pages/__pycache__/page_account.cpython-311.pyc
ADDED
|
Binary file (4.36 kB). View file
|
|
|
src/pages/__pycache__/page_history.cpython-311.pyc
ADDED
|
Binary file (2.86 kB). View file
|
|
|
src/pages/__pycache__/page_report.cpython-311.pyc
ADDED
|
Binary file (7.27 kB). View file
|
|
|
src/pages/page_account.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from auth import (
|
| 3 |
+
get_user_profile,
|
| 4 |
+
update_username_in_mongo,
|
| 5 |
+
save_avatar,
|
| 6 |
+
get_avatar_blob,
|
| 7 |
+
)
|
| 8 |
+
from utils import load_css, render_avatar
|
| 9 |
+
|
| 10 |
+
def main():
|
| 11 |
+
load_css()
|
| 12 |
+
|
| 13 |
+
if "uid" not in st.session_state:
|
| 14 |
+
st.warning("Vui lòng đăng nhập để sử dụng tính năng này.")
|
| 15 |
+
st.page_link("StockInsights.py", label="Về trang Đăng nhập", icon="🏠")
|
| 16 |
+
st.stop()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ==== Giao diện trang tài khoản ====
|
| 20 |
+
st.markdown("<h2>Thông tin Tài khoản</h2>", unsafe_allow_html=True)
|
| 21 |
+
|
| 22 |
+
_, col_center, _ = st.columns([1, 2, 1])
|
| 23 |
+
|
| 24 |
+
with col_center:
|
| 25 |
+
profile = get_user_profile(st.session_state["uid"])
|
| 26 |
+
|
| 27 |
+
render_avatar(st.session_state["uid"], st, get_avatar_blob)
|
| 28 |
+
|
| 29 |
+
st.html(f"<h3 style='text-align: center; color: #ffffff; margin-bottom: 0.25rem; font-weight: 600;'>{profile.get('user_name', '')}</h3>")
|
| 30 |
+
st.html(f"<p style='text-align: center; color: #94a3b8; margin-bottom: 2rem;'>{st.session_state['user_email']}</p>")
|
| 31 |
+
|
| 32 |
+
with st.expander("⚙️ Chỉnh sửa thông tin"):
|
| 33 |
+
new_username = st.text_input("Tên người dùng mới", value=profile.get("user_name", ""), key="edit_username")
|
| 34 |
+
if st.button("Lưu tên mới", use_container_width=True, key="save_name"):
|
| 35 |
+
if new_username:
|
| 36 |
+
update_username_in_mongo(st.session_state["uid"], new_username)
|
| 37 |
+
st.success("Đã cập nhật tên người dùng!")
|
| 38 |
+
st.rerun()
|
| 39 |
+
else:
|
| 40 |
+
st.warning("Tên người dùng không được để trống.")
|
| 41 |
+
|
| 42 |
+
st.markdown("<hr style='margin: 1rem 0; border-color: rgba(100, 116, 139, 0.3);'>", unsafe_allow_html=True)
|
| 43 |
+
|
| 44 |
+
file = st.file_uploader("Thay đổi ảnh đại diện (png, jpg, jpeg)", type=["png", "jpg", "jpeg"])
|
| 45 |
+
if st.button("Lưu avatar", use_container_width=True, key="save_avatar"):
|
| 46 |
+
if file:
|
| 47 |
+
save_avatar(st.session_state["uid"], file.read())
|
| 48 |
+
st.success("Đã lưu avatar!")
|
| 49 |
+
st.rerun()
|
| 50 |
+
else:
|
| 51 |
+
st.warning("Hãy chọn ảnh trước khi lưu.")
|
| 52 |
+
|
| 53 |
+
st.markdown("<div style='margin-top: 1rem;'></div>", unsafe_allow_html=True)
|
| 54 |
+
|
| 55 |
+
if st.button("Đăng xuất", use_container_width=True, key="logout_btn"):
|
| 56 |
+
st.session_state.clear()
|
| 57 |
+
st.rerun()
|
| 58 |
+
#st.switch_page("StockInsights.py")
|
src/pages/page_history.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
# st.set_page_config(page_title="Lịch sử - Stock Insights", page_icon="🔮", layout="centered")
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from pymongo import MongoClient
|
| 6 |
+
from report_generator import show_report
|
| 7 |
+
from utils import load_css
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
mongo_uri = os.getenv("MONGO_URI")
|
| 11 |
+
mongo_dbname = os.getenv("MONGO_DBNAME")
|
| 12 |
+
|
| 13 |
+
def get_db():
|
| 14 |
+
client = MongoClient(mongo_uri)
|
| 15 |
+
db = client[mongo_dbname]
|
| 16 |
+
return db["reports"]
|
| 17 |
+
|
| 18 |
+
reports_history = get_db()
|
| 19 |
+
|
| 20 |
+
load_css()
|
| 21 |
+
|
| 22 |
+
if "uid" not in st.session_state:
|
| 23 |
+
st.warning("Vui lòng đăng nhập để sử dụng tính năng này.")
|
| 24 |
+
st.page_link("StockInsights.py", label="Về trang Đăng nhập", icon="🏠")
|
| 25 |
+
st.stop()
|
| 26 |
+
|
| 27 |
+
# ==== Giao diện trang lịch sử ====
|
| 28 |
+
st.markdown("<h2>Các báo cáo đã tạo</h2>", unsafe_allow_html=True)
|
| 29 |
+
search_code = st.text_input("Tìm theo mã cổ phiếu", "").upper()
|
| 30 |
+
|
| 31 |
+
query = {"uid": st.session_state["uid"]}
|
| 32 |
+
if search_code:
|
| 33 |
+
query["report_data.stock_code"] = {"$regex": f"^{search_code}", "$options": "i"}
|
| 34 |
+
|
| 35 |
+
history = list(reports_history.find(query).sort("created_at", -1))
|
| 36 |
+
|
| 37 |
+
if history:
|
| 38 |
+
for item in history:
|
| 39 |
+
stock_code = item["report_data"]["stock_code"]
|
| 40 |
+
period = item["report_data"]["report_period"]
|
| 41 |
+
with st.expander(f"{stock_code}: {period}"):
|
| 42 |
+
show_report(item["report_data"], item["summary"], item["report_data"]["stock_code"])
|
| 43 |
+
else:
|
| 44 |
+
st.info("Chưa có báo cáo nào cả")
|
src/pages/page_report.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
from pymongo import MongoClient
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from report_generator import generate_stock_report, show_report
|
| 7 |
+
from utils import load_css, call_genai_summary
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
mongo_uri = os.getenv("MONGO_URI")
|
| 11 |
+
mongo_dbname = os.getenv("MONGO_DBNAME")
|
| 12 |
+
|
| 13 |
+
def get_db():
|
| 14 |
+
client = MongoClient(mongo_uri)
|
| 15 |
+
db = client[mongo_dbname]
|
| 16 |
+
return db["reports"]
|
| 17 |
+
|
| 18 |
+
reports_history = get_db()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
load_css()
|
| 22 |
+
|
| 23 |
+
if "uid" not in st.session_state:
|
| 24 |
+
st.warning("Vui lòng đăng nhập để sử dụng tính năng này.")
|
| 25 |
+
st.page_link("StockInsights.py", label="Về trang Đăng nhập", icon="🏠")
|
| 26 |
+
st.stop()
|
| 27 |
+
|
| 28 |
+
# ==== Giao diện trang báo cáo ===
|
| 29 |
+
st.markdown("<h2>Báo cáo Cổ phiếu Thông minh</h2>", unsafe_allow_html=True)
|
| 30 |
+
|
| 31 |
+
# st.markdown("<div class='report-container'>", unsafe_allow_html=True)
|
| 32 |
+
|
| 33 |
+
col_history, col_main = st.columns([1, 3])
|
| 34 |
+
|
| 35 |
+
# ===== LEFT: Report History =====
|
| 36 |
+
with col_history:
|
| 37 |
+
st.markdown("<h4>Báo cáo đã xem</h4>", unsafe_allow_html=True)
|
| 38 |
+
|
| 39 |
+
# Initialize history list
|
| 40 |
+
if "reports_history_list" not in st.session_state:
|
| 41 |
+
query = {"uid": st.session_state["uid"]}
|
| 42 |
+
st.session_state["reports_history_list"] = list(
|
| 43 |
+
reports_history.find(query).sort("created_at", -1)
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
history = st.session_state["reports_history_list"]
|
| 47 |
+
|
| 48 |
+
if not history:
|
| 49 |
+
st.info("Chưa có báo cáo nào được lưu.")
|
| 50 |
+
else:
|
| 51 |
+
for idx, report in enumerate(history):
|
| 52 |
+
if st.button(
|
| 53 |
+
f"{report['report_data'].get('stock_code', 'N/A')} ({report['report_data'].get('report_period', 'N/A')})",
|
| 54 |
+
key=f"history_btn_{idx}"
|
| 55 |
+
):
|
| 56 |
+
# Update the selected report in session state
|
| 57 |
+
st.session_state["selected_report"] = report
|
| 58 |
+
st.session_state["show_form"] = False # Hide form when viewing history
|
| 59 |
+
|
| 60 |
+
# ===== RIGHT: Report View=====
|
| 61 |
+
with col_main:
|
| 62 |
+
if st.session_state.get("selected_report") and not st.session_state.get("show_form", False):
|
| 63 |
+
selected = st.session_state["selected_report"]
|
| 64 |
+
show_report(selected["report_data"], selected["summary"], selected["report_data"]["stock_code"])
|
| 65 |
+
|
| 66 |
+
# Tạo báo cáo mới
|
| 67 |
+
if st.button("Tạo báo cáo mới", key="new_report_btn"):
|
| 68 |
+
st.session_state["show_form"] = True
|
| 69 |
+
st.session_state.pop("selected_report", None)
|
| 70 |
+
st.rerun()
|
| 71 |
+
|
| 72 |
+
else:
|
| 73 |
+
with st.form("report_form"):
|
| 74 |
+
stock_code_input = st.text_input(
|
| 75 |
+
"Nhập mã cổ phiếu (ví dụ: VIC, HPG...)", value="HPG").upper()
|
| 76 |
+
|
| 77 |
+
col_start, col_end = st.columns(2)
|
| 78 |
+
with col_start:
|
| 79 |
+
start_date = st.date_input(
|
| 80 |
+
"Từ ngày", value=pd.to_datetime("2025-05-01"))
|
| 81 |
+
with col_end:
|
| 82 |
+
end_date = st.date_input("Đến ngày", value=datetime.now())
|
| 83 |
+
|
| 84 |
+
submitted = st.form_submit_button("Tạo báo cáo", use_container_width=True)
|
| 85 |
+
|
| 86 |
+
if submitted and stock_code_input:
|
| 87 |
+
with st.spinner(f'Đang tổng hợp và phân tích dữ liệu cho mã {stock_code_input}...'):
|
| 88 |
+
report_data = generate_stock_report(
|
| 89 |
+
stock_code_input, (str(start_date), str(end_date)))
|
| 90 |
+
|
| 91 |
+
if report_data and (report_data["overall_sentiment"]["positive_mentions"] > 0 or report_data["overall_sentiment"]["negative_mentions"] > 0):
|
| 92 |
+
summary = call_genai_summary(
|
| 93 |
+
report_data, stock_code_input, (str(start_date), str(end_date)))
|
| 94 |
+
else:
|
| 95 |
+
summary = f"Không tìm thấy đủ dữ liệu nổi bật cho mã **{stock_code_input}** trong khoảng thời gian đã chọn để tạo tóm tắt AI."
|
| 96 |
+
|
| 97 |
+
# Save to MongoDB
|
| 98 |
+
inserted_id = reports_history.insert_one({
|
| 99 |
+
"uid": st.session_state["uid"],
|
| 100 |
+
"report_data": report_data,
|
| 101 |
+
"summary": summary,
|
| 102 |
+
"created_at": datetime.utcnow()
|
| 103 |
+
}).inserted_id
|
| 104 |
+
|
| 105 |
+
# Add new report to top of history
|
| 106 |
+
new_report = {
|
| 107 |
+
"_id": inserted_id,
|
| 108 |
+
"report_data": report_data,
|
| 109 |
+
"summary": summary,
|
| 110 |
+
"created_at": datetime.utcnow()
|
| 111 |
+
}
|
| 112 |
+
st.session_state["reports_history_list"].insert(0, new_report)
|
| 113 |
+
|
| 114 |
+
# Show new report
|
| 115 |
+
st.session_state["selected_report"] = new_report
|
| 116 |
+
st.session_state["show_form"] = False
|
| 117 |
+
st.rerun()
|
src/report_generator.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import os
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
# def get_db_path():
|
| 8 |
+
# db_path = "../database/stock_insights.db"
|
| 9 |
+
# if not os.path.exists(db_path) and os.path.exists("/tmp/stock_insights.db"):
|
| 10 |
+
# db_path = "/tmp/stock_insights.db"
|
| 11 |
+
# return db_path
|
| 12 |
+
|
| 13 |
+
# download from Hugging Face dataset
|
| 14 |
+
def ensure_db():
|
| 15 |
+
db_path = os.path.join("database", "stock_insights.db")
|
| 16 |
+
if not os.path.exists(db_path):
|
| 17 |
+
from huggingface_hub import hf_hub_download
|
| 18 |
+
os.makedirs("database", exist_ok=True)
|
| 19 |
+
hf_hub_download(
|
| 20 |
+
repo_id="PuppetLover/stock_insights",
|
| 21 |
+
filename="stock_insights.db",
|
| 22 |
+
repo_type="dataset",
|
| 23 |
+
local_dir="database",
|
| 24 |
+
local_dir_use_symlinks=False,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
ensure_db()
|
| 28 |
+
|
| 29 |
+
def generate_stock_report(stock_code, time_period):
|
| 30 |
+
|
| 31 |
+
start_date, end_date = time_period
|
| 32 |
+
today = datetime.now().date()
|
| 33 |
+
db_path = os.path.join("database", "stock_insights.db")
|
| 34 |
+
|
| 35 |
+
report = {
|
| 36 |
+
"stock_code": stock_code,
|
| 37 |
+
"report_period": f"{start_date} to {end_date}"
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
with sqlite3.connect(db_path) as conn:
|
| 41 |
+
# Tạo bảng tạm relevant_articles
|
| 42 |
+
conn.execute("DROP TABLE IF EXISTS relevant_articles;")
|
| 43 |
+
conn.execute("""
|
| 44 |
+
CREATE TEMP TABLE relevant_articles AS
|
| 45 |
+
SELECT DISTINCT article_id FROM entities
|
| 46 |
+
WHERE entity_text =?
|
| 47 |
+
AND entity_type IN ('STOCK', 'COMPANY')
|
| 48 |
+
AND confidence = 'high'
|
| 49 |
+
AND article_id IN (
|
| 50 |
+
SELECT article_id FROM articles WHERE publish_date BETWEEN ? AND ?
|
| 51 |
+
);
|
| 52 |
+
""", (stock_code, start_date, end_date))
|
| 53 |
+
|
| 54 |
+
# 1. OVERALL SENTIMENT
|
| 55 |
+
q_sentences = """
|
| 56 |
+
SELECT s.sentiment_score, s.sentiment_label, a.publish_date
|
| 57 |
+
FROM sentences s
|
| 58 |
+
JOIN articles a ON s.article_id = a.article_id
|
| 59 |
+
WHERE s.article_id IN (
|
| 60 |
+
SELECT s2.sentence_id FROM sentences s2
|
| 61 |
+
WHERE s2.article_id IN (SELECT article_id FROM relevant_articles)
|
| 62 |
+
)
|
| 63 |
+
AND s.sentiment_score IS NOT NULL;
|
| 64 |
+
"""
|
| 65 |
+
df_sent = pd.read_sql_query(q_sentences, conn)
|
| 66 |
+
|
| 67 |
+
if not df_sent.empty:
|
| 68 |
+
df_sent['publish_date'] = pd.to_datetime(df_sent['publish_date']).dt.date
|
| 69 |
+
df_sent['days_ago'] = (today - df_sent['publish_date']).apply(lambda x: x.days)
|
| 70 |
+
df_sent['weight'] = 1 / (df_sent['days_ago'] + 1)
|
| 71 |
+
weighted_score = (df_sent['sentiment_score'] * df_sent['weight']).sum() / df_sent['weight'].sum()
|
| 72 |
+
# Chuẩn hóa nhãn sentiment về lower-case
|
| 73 |
+
df_sent['sentiment_label'] = df_sent['sentiment_label'].str.lower()
|
| 74 |
+
sentiment_counts = df_sent['sentiment_label'].value_counts().to_dict()
|
| 75 |
+
trend = "Tích cực" if weighted_score > 0.1 else "Tiêu cực" if weighted_score < -0.1 else "Trung tính"
|
| 76 |
+
else:
|
| 77 |
+
weighted_score, sentiment_counts, trend = 0.0, {}, "Không có dữ liệu"
|
| 78 |
+
|
| 79 |
+
report["overall_sentiment"] = {
|
| 80 |
+
"score": weighted_score,
|
| 81 |
+
"trend": trend,
|
| 82 |
+
"positive_mentions": sentiment_counts.get("positive", 0),
|
| 83 |
+
"negative_mentions": sentiment_counts.get("negative", 0),
|
| 84 |
+
"neutral_mentions": sentiment_counts.get("neutral", 0)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
# 2. KEY EVENTS, RISKS, PRICE ACTIONS
|
| 88 |
+
def get_key_entities(entity_type):
|
| 89 |
+
query = f"""
|
| 90 |
+
SELECT
|
| 91 |
+
e.entity_text,
|
| 92 |
+
COUNT(e.entity_id) as count,
|
| 93 |
+
AVG(s.sentiment_score) as avg_sentiment
|
| 94 |
+
FROM entities e
|
| 95 |
+
JOIN sentences s ON e.sentence_id = s.sentence_id
|
| 96 |
+
WHERE e.article_id IN (SELECT article_id FROM relevant_articles)
|
| 97 |
+
AND e.entity_type =?
|
| 98 |
+
GROUP BY e.entity_text
|
| 99 |
+
ORDER BY count DESC
|
| 100 |
+
LIMIT 5;
|
| 101 |
+
"""
|
| 102 |
+
df = pd.read_sql_query(query, conn, params=(entity_type,))
|
| 103 |
+
def score_to_label(score):
|
| 104 |
+
if score is None: return "N/A"
|
| 105 |
+
return "Tích cực" if score > 0.1 else "Tiêu cực" if score < -0.1 else "Trung tính"
|
| 106 |
+
df['sentiment'] = df['avg_sentiment'].apply(score_to_label)
|
| 107 |
+
return df.to_dict('records')
|
| 108 |
+
|
| 109 |
+
report["key_events"] = get_key_entities('EVENT')
|
| 110 |
+
report["key_price_actions"] = get_key_entities('PRICE_ACTION')
|
| 111 |
+
report["key_risks_mentioned"] = get_key_entities('RISK')
|
| 112 |
+
|
| 113 |
+
# 3. TOP RELATED ENTITIES
|
| 114 |
+
q_related = """
|
| 115 |
+
SELECT e.entity_type, e.entity_text
|
| 116 |
+
FROM entities e
|
| 117 |
+
WHERE e.article_id IN (SELECT article_id FROM relevant_articles)
|
| 118 |
+
AND e.entity_text!=?
|
| 119 |
+
AND e.entity_type IN ('STOCK', 'COMPANY', 'PERSON');
|
| 120 |
+
"""
|
| 121 |
+
df_related = pd.read_sql_query(q_related, conn, params=(stock_code,))
|
| 122 |
+
top_related = {}
|
| 123 |
+
if not df_related.empty:
|
| 124 |
+
for etype in ['STOCK', 'COMPANY', 'PERSON']:
|
| 125 |
+
top_related[etype.lower() + 's'] = df_related[df_related['entity_type'] == etype]['entity_text'].value_counts().head(3).index.tolist()
|
| 126 |
+
report["top_related_entities"] = top_related
|
| 127 |
+
|
| 128 |
+
# 4. SOURCE ARTICLES
|
| 129 |
+
q_articles = """
|
| 130 |
+
SELECT a.title, a.source_url, s.sentiment_label
|
| 131 |
+
FROM articles a
|
| 132 |
+
JOIN sentences s ON a.article_id = s.article_id
|
| 133 |
+
WHERE a.article_id IN (SELECT article_id FROM relevant_articles)
|
| 134 |
+
GROUP BY a.article_id
|
| 135 |
+
ORDER BY a.publish_date DESC
|
| 136 |
+
LIMIT 5;
|
| 137 |
+
"""
|
| 138 |
+
df_articles = pd.read_sql_query(q_articles, conn)
|
| 139 |
+
report["source_articles"] = df_articles.to_dict('records')
|
| 140 |
+
|
| 141 |
+
return report
|
| 142 |
+
|
| 143 |
+
# --- HIỂN THỊ BÁO CÁO ---
|
| 144 |
+
def show_report(report_data, summary, stock_code_input):
|
| 145 |
+
st.markdown(
|
| 146 |
+
f"<h3 style='text-align: center; color: #30cfd0; margin-top:2rem;'>Báo cáo Phân tích cho {report_data.get('stock_code', stock_code_input)}</h3>", unsafe_allow_html=True)
|
| 147 |
+
st.markdown(
|
| 148 |
+
f"<p style='text-align: center; color: #94a3b8;'>Giai đoạn: {report_data.get('report_period', 'N/A')}</p>", unsafe_allow_html=True)
|
| 149 |
+
|
| 150 |
+
st.markdown("#### 🤖 Tóm tắt từ AI")
|
| 151 |
+
st.info(summary)
|
| 152 |
+
|
| 153 |
+
# Tổng quan cảm xúc
|
| 154 |
+
st.markdown("#### 📊 Tổng quan Cảm xúc")
|
| 155 |
+
sentiment = report_data['overall_sentiment']
|
| 156 |
+
score = sentiment['score']
|
| 157 |
+
trend_color = "normal"
|
| 158 |
+
if sentiment['trend'] == "Tích cực":
|
| 159 |
+
trend_color = "normal"
|
| 160 |
+
if sentiment['trend'] == "Tiêu cực":
|
| 161 |
+
trend_color = "inverse"
|
| 162 |
+
|
| 163 |
+
st.metric(
|
| 164 |
+
label="Điểm Cảm xúc (có trọng số thời gian)",
|
| 165 |
+
value=f"{score:.2f}" if score is not None else "N/A",
|
| 166 |
+
delta=sentiment['trend'],
|
| 167 |
+
delta_color=trend_color
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
col1, col2, col3 = st.columns(3)
|
| 171 |
+
col1.metric("👍 Tích cực", sentiment['positive_mentions'])
|
| 172 |
+
col2.metric("👎 Tiêu cực", sentiment['negative_mentions'])
|
| 173 |
+
col3.metric("😐 Trung tính", sentiment['neutral_mentions'])
|
| 174 |
+
|
| 175 |
+
# Các bảng chi tiết
|
| 176 |
+
st.markdown("---")
|
| 177 |
+
|
| 178 |
+
col_events, col_risks = st.columns(2)
|
| 179 |
+
with col_events:
|
| 180 |
+
st.markdown("#### ⚡ Sự kiện Nổi bật")
|
| 181 |
+
if report_data["key_events"]:
|
| 182 |
+
# Kiểm tra key thực tế
|
| 183 |
+
df_events = pd.DataFrame(report_data["key_events"])
|
| 184 |
+
if 'avg_sentiment' in df_events.columns:
|
| 185 |
+
df_events = df_events.rename(
|
| 186 |
+
columns={'entity_text': 'Sự kiện', 'avg_sentiment': 'Sentiment'})
|
| 187 |
+
show_cols = ['Sự kiện', 'count', 'Sentiment']
|
| 188 |
+
elif 'sentiment' in df_events.columns:
|
| 189 |
+
df_events = df_events.rename(
|
| 190 |
+
columns={'entity_text': 'Sự kiện'})
|
| 191 |
+
show_cols = ['Sự kiện', 'count', 'sentiment']
|
| 192 |
+
else:
|
| 193 |
+
df_events = df_events.rename(
|
| 194 |
+
columns={'entity_text': 'Sự kiện'})
|
| 195 |
+
show_cols = ['Sự kiện', 'count']
|
| 196 |
+
st.dataframe(df_events[show_cols], use_container_width=True)
|
| 197 |
+
else:
|
| 198 |
+
st.write("Không có sự kiện nổi bật.")
|
| 199 |
+
|
| 200 |
+
with col_risks:
|
| 201 |
+
st.markdown("#### ⚠️ Rủi ro được đề cập")
|
| 202 |
+
if report_data["key_risks_mentioned"]:
|
| 203 |
+
df_risks = pd.DataFrame(report_data["key_risks_mentioned"])
|
| 204 |
+
if 'avg_sentiment' in df_risks.columns:
|
| 205 |
+
df_risks = df_risks.rename(
|
| 206 |
+
columns={'entity_text': 'Rủi ro', 'avg_sentiment': 'Sentiment'})
|
| 207 |
+
show_cols = ['Rủi ro', 'count', 'Sentiment']
|
| 208 |
+
elif 'sentiment' in df_risks.columns:
|
| 209 |
+
df_risks = df_risks.rename(
|
| 210 |
+
columns={'entity_text': 'Rủi ro'})
|
| 211 |
+
show_cols = ['Rủi ro', 'count', 'sentiment']
|
| 212 |
+
else:
|
| 213 |
+
df_risks = df_risks.rename(
|
| 214 |
+
columns={'entity_text': 'Rủi ro'})
|
| 215 |
+
show_cols = ['Rủi ro', 'count']
|
| 216 |
+
st.dataframe(df_risks[show_cols], use_container_width=True)
|
| 217 |
+
else:
|
| 218 |
+
st.write("Không có rủi ro nổi bật.")
|
| 219 |
+
|
| 220 |
+
st.markdown("#### 📈 Hành động Giá Chính")
|
| 221 |
+
if report_data["key_price_actions"]:
|
| 222 |
+
df_price = pd.DataFrame(report_data["key_price_actions"])
|
| 223 |
+
if 'avg_sentiment' in df_price.columns:
|
| 224 |
+
df_price = df_price.rename(
|
| 225 |
+
columns={'entity_text': 'Hành động giá', 'avg_sentiment': 'Sentiment'})
|
| 226 |
+
show_cols = ['Hành động giá', 'count', 'Sentiment']
|
| 227 |
+
elif 'sentiment' in df_price.columns:
|
| 228 |
+
df_price = df_price.rename(
|
| 229 |
+
columns={'entity_text': 'Hành động giá'})
|
| 230 |
+
show_cols = ['Hành động giá', 'count', 'sentiment']
|
| 231 |
+
else:
|
| 232 |
+
df_price = df_price.rename(
|
| 233 |
+
columns={'entity_text': 'Hành động giá'})
|
| 234 |
+
show_cols = ['Hành động giá', 'count']
|
| 235 |
+
st.dataframe(df_price[show_cols], use_container_width=True)
|
| 236 |
+
else:
|
| 237 |
+
st.write("Không có hành động giá nổi bật.")
|
| 238 |
+
|
| 239 |
+
# Thực thể liên quan
|
| 240 |
+
st.markdown("---")
|
| 241 |
+
st.markdown("#### 🔗 Các Thực thể Liên quan nhiều nhất")
|
| 242 |
+
related = report_data['top_related_entities']
|
| 243 |
+
if any(related.values()):
|
| 244 |
+
for etype, entities in related.items():
|
| 245 |
+
if entities:
|
| 246 |
+
st.markdown(
|
| 247 |
+
f"**{etype.replace('_', ' ').title()}:** {', '.join(entities)}")
|
| 248 |
+
else:
|
| 249 |
+
st.write("Không tìm thấy thực thể liên quan nổi bật.")
|
| 250 |
+
|
| 251 |
+
# Nguồn bài viết
|
| 252 |
+
st.markdown("---")
|
| 253 |
+
st.markdown("#### 📰 Nguồn Bài viết Tham khảo")
|
| 254 |
+
if report_data["source_articles"]:
|
| 255 |
+
for article in report_data["source_articles"]:
|
| 256 |
+
st.markdown(
|
| 257 |
+
f"- [{article['title']}]({article['source_url']}) - *Cảm xúc: {article['sentiment_label']}*")
|
| 258 |
+
else:
|
| 259 |
+
st.write("Không có bài viết nào trong khoảng thời gian này.")
|
| 260 |
+
|
| 261 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
src/requirements.txt
ADDED
|
Binary file (7.24 kB). View file
|
|
|
src/streamlit_app.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
import altair as alt
|
| 2 |
-
import numpy as np
|
| 3 |
-
import pandas as pd
|
| 4 |
-
import streamlit as st
|
| 5 |
-
|
| 6 |
-
"""
|
| 7 |
-
# Welcome to Streamlit!
|
| 8 |
-
|
| 9 |
-
Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
|
| 10 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 11 |
-
forums](https://discuss.streamlit.io).
|
| 12 |
-
|
| 13 |
-
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
-
|
| 19 |
-
indices = np.linspace(0, 1, num_points)
|
| 20 |
-
theta = 2 * np.pi * num_turns * indices
|
| 21 |
-
radius = indices
|
| 22 |
-
|
| 23 |
-
x = radius * np.cos(theta)
|
| 24 |
-
y = radius * np.sin(theta)
|
| 25 |
-
|
| 26 |
-
df = pd.DataFrame({
|
| 27 |
-
"x": x,
|
| 28 |
-
"y": y,
|
| 29 |
-
"idx": indices,
|
| 30 |
-
"rand": np.random.randn(num_points),
|
| 31 |
-
})
|
| 32 |
-
|
| 33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
-
.mark_point(filled=True)
|
| 35 |
-
.encode(
|
| 36 |
-
x=alt.X("x", axis=None),
|
| 37 |
-
y=alt.Y("y", axis=None),
|
| 38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/utils.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pyrebase
|
| 3 |
+
import os
|
| 4 |
+
import base64
|
| 5 |
+
import json
|
| 6 |
+
import requests
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
# ==== Firebase Config & Initialization ====
|
| 12 |
+
def initialize_firebase():
|
| 13 |
+
"""Khởi tạo và trả về các đối tượng Firebase. Sử dụng singleton pattern."""
|
| 14 |
+
if "firebase_app" not in st.session_state:
|
| 15 |
+
firebase_config = {
|
| 16 |
+
"apiKey": os.getenv("FIREBASE_API_KEY"),
|
| 17 |
+
"authDomain": os.getenv("FIREBASE_AUTH_DOMAIN"),
|
| 18 |
+
"projectId": os.getenv("FIREBASE_PROJECT_ID"),
|
| 19 |
+
"storageBucket": os.getenv("FIREBASE_STORAGE_BUCKET"),
|
| 20 |
+
"messagingSenderId": os.getenv("FIREBASE_MESSAGING_SENDER_ID"),
|
| 21 |
+
"appId": os.getenv("FIREBASE_APP_ID"),
|
| 22 |
+
"databaseURL": os.getenv("FIREBASE_DATABASE_URL", ""),
|
| 23 |
+
}
|
| 24 |
+
st.session_state.firebase_app = pyrebase.initialize_app(firebase_config)
|
| 25 |
+
|
| 26 |
+
auth_fb = st.session_state.firebase_app.auth()
|
| 27 |
+
return auth_fb
|
| 28 |
+
|
| 29 |
+
# ==== CSS Dùng chung ====
|
| 30 |
+
def load_css():
|
| 31 |
+
"""Tải CSS theme Cyberpunk Neon."""
|
| 32 |
+
st.markdown("""
|
| 33 |
+
<style>
|
| 34 |
+
/* === Hide default Streamlit elements === */
|
| 35 |
+
section[data-testid="stSidebar"] {display: none;}
|
| 36 |
+
header {visibility: hidden;}
|
| 37 |
+
|
| 38 |
+
/* === Main container styling === */
|
| 39 |
+
.stApp {
|
| 40 |
+
background-color: #0d1117;
|
| 41 |
+
color: #c9d1d9;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* === Main content area styling === */
|
| 45 |
+
.main .block-container {
|
| 46 |
+
max-width: 1100px; /* Rộng hơn cho layout mới */
|
| 47 |
+
padding: 1rem 1.5rem;
|
| 48 |
+
}
|
| 49 |
+
.not-logged-in .main .block-container {
|
| 50 |
+
max-width: 450px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* === Custom Navigation Bar === */
|
| 54 |
+
.nav-container {
|
| 55 |
+
display: flex;
|
| 56 |
+
justify-content: center;
|
| 57 |
+
gap: 1rem;
|
| 58 |
+
margin-bottom: 2rem;
|
| 59 |
+
padding: 0.5rem;
|
| 60 |
+
background-color: rgba(30, 41, 59, 0.5);
|
| 61 |
+
border-radius: 12px;
|
| 62 |
+
border: 1px solid rgba(100, 116, 139, 0.3);
|
| 63 |
+
}
|
| 64 |
+
.nav-container .stButton>button {
|
| 65 |
+
background: transparent;
|
| 66 |
+
border: 2px solid transparent;
|
| 67 |
+
transition: all 0.3s ease;
|
| 68 |
+
font-weight: 600;
|
| 69 |
+
color: #94a3b8;
|
| 70 |
+
}
|
| 71 |
+
.nav-container .stButton>button:hover {
|
| 72 |
+
color: #ffffff;
|
| 73 |
+
border-color: rgba(48, 207, 208, 0.5);
|
| 74 |
+
box-shadow: none;
|
| 75 |
+
transform: none; /* FIX: Vô hiệu hóa hiệu ứng transform cho nút nav */
|
| 76 |
+
}
|
| 77 |
+
/* Style for the ACTIVE button */
|
| 78 |
+
.nav-container .stButton>button.active-nav-button {
|
| 79 |
+
color: #ffffff;
|
| 80 |
+
border-color: #30cfd0;
|
| 81 |
+
box-shadow: 0 0 15px rgba(48, 207, 208, 0.4);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* === Card styling with "frosted glass" effect === */
|
| 85 |
+
div[data-testid="stTabs-panel"], .report-container {
|
| 86 |
+
background-color: rgba(30, 41, 59, 0.5);
|
| 87 |
+
backdrop-filter: blur(12px);
|
| 88 |
+
border-radius: 16px;
|
| 89 |
+
padding: 2.5rem;
|
| 90 |
+
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
| 91 |
+
border: 1px solid rgba(100, 116, 139, 0.3);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* === General Button styling === */
|
| 95 |
+
.stButton>button {
|
| 96 |
+
border-radius: 8px;
|
| 97 |
+
border: 1px solid #30cfd0;
|
| 98 |
+
padding: 12px 20px;
|
| 99 |
+
color: white;
|
| 100 |
+
background: linear-gradient(90deg, #30cfd0, #330867);
|
| 101 |
+
transition: all 0.3s ease-in-out;
|
| 102 |
+
font-weight: 600;
|
| 103 |
+
}
|
| 104 |
+
.stButton>button:hover {
|
| 105 |
+
box-shadow: 0 0 20px #30cfd0;
|
| 106 |
+
transform: translateY(-2px);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* === Input fields styling === */
|
| 110 |
+
.stTextInput label, .stDateInput label {
|
| 111 |
+
color: #c9d1d9 !important;
|
| 112 |
+
font-weight: 600;
|
| 113 |
+
margin-bottom: 0.5rem;
|
| 114 |
+
}
|
| 115 |
+
.stTextInput>div>div>input, .stDateInput>div>div>input {
|
| 116 |
+
background-color: rgba(15, 23, 42, 0.5);
|
| 117 |
+
border: 1px solid #64748b;
|
| 118 |
+
border-radius: 8px;
|
| 119 |
+
padding: 12px;
|
| 120 |
+
color: #ffffff;
|
| 121 |
+
}
|
| 122 |
+
.stTextInput>div>div>input:focus, .stDateInput>div>div>input:focus {
|
| 123 |
+
border-color: #30cfd0;
|
| 124 |
+
box-shadow: 0 0 10px rgba(48, 207, 208, 0.5);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/* === Header styling === */
|
| 128 |
+
h1, h2 {
|
| 129 |
+
text-align: center;
|
| 130 |
+
color: #ffffff;
|
| 131 |
+
font-weight: 700;
|
| 132 |
+
letter-spacing: 1px;
|
| 133 |
+
text-shadow: 0 0 10px rgba(48, 207, 208, 0.5);
|
| 134 |
+
}
|
| 135 |
+
</style>
|
| 136 |
+
""", unsafe_allow_html=True)
|
| 137 |
+
|
| 138 |
+
# ==== Hàm Render Avatar ====
|
| 139 |
+
def render_avatar(uid, container, get_avatar_blob_func):
|
| 140 |
+
avatar_bytes = get_avatar_blob_func(uid)
|
| 141 |
+
if avatar_bytes:
|
| 142 |
+
img_base64 = base64.b64encode(avatar_bytes).decode()
|
| 143 |
+
avatar_html = f'<img src="data:image/png;base64,{img_base64}" style="border-radius:50%; border:4px solid #30cfd0; width:120px; height:120px; object-fit:cover; box-shadow:0 0 20px rgba(48, 207, 208, 0.5);">'
|
| 144 |
+
else:
|
| 145 |
+
avatar_html = """
|
| 146 |
+
<div style='border-radius:50%; background:linear-gradient(135deg, #30cfd0, #330867);
|
| 147 |
+
width:120px; height:120px; display:flex; align-items:center; justify-content:center;
|
| 148 |
+
box-shadow:0 0 20px rgba(48, 207, 208, 0.3);'>
|
| 149 |
+
<span style='font-size:3em; color:#fff;'>👤</span>
|
| 150 |
+
</div>
|
| 151 |
+
<div style='margin-top:8px; color:#94a3b8; font-size:0.9em;'>Chưa có avatar</div>
|
| 152 |
+
"""
|
| 153 |
+
container.html(f"<div style='display:flex; flex-direction:column; align-items:center; margin-bottom: 1rem;'>{avatar_html}</div>")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ==== Hàm gọi Gemini API ====
|
| 157 |
+
def call_genai_summary(report_data, stock_code, time_period):
|
| 158 |
+
api_key = os.getenv("GEMINI_API_KEY")
|
| 159 |
+
if not api_key:
|
| 160 |
+
st.error("Vui lòng cung cấp GEMINI_API_KEY trong file .env")
|
| 161 |
+
return "Lỗi: Chưa cấu hình API Key."
|
| 162 |
+
|
| 163 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
|
| 164 |
+
prompt = f"""
|
| 165 |
+
Hãy tóm tắt ngắn gọn, chuyên nghiệp về mã cổ phiếu {stock_code} trong giai đoạn {time_period[0]} đến {time_period[1]} dựa trên dữ liệu JSON sau:
|
| 166 |
+
{json.dumps(report_data, ensure_ascii=False, indent=2)}
|
| 167 |
+
"""
|
| 168 |
+
payload = {"contents": [{"parts": [{"text": prompt}]}]}
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
response = requests.post(url, json=payload, timeout=45)
|
| 172 |
+
response.raise_for_status()
|
| 173 |
+
data = response.json()
|
| 174 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 175 |
+
except Exception as e:
|
| 176 |
+
return f"Lỗi khi gọi Gemini API: {e}"
|