feat: add reporting workflow and optimize dashboard loading
This commit is contained in:
@ -23,6 +23,7 @@
|
||||
- 关注池写入数据库,支持新增和删除。
|
||||
- 个股详情支持 K 线、MA5、买卖点与预警信息展示。
|
||||
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
||||
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
|
||||
|
||||
## 环境要求
|
||||
|
||||
|
||||
@ -35,6 +35,15 @@ monitoring:
|
||||
- 5
|
||||
turnover_warning_threshold: 0.30
|
||||
|
||||
mail:
|
||||
sender_email: "your_email@example.com"
|
||||
smtp_username: "your_email@example.com"
|
||||
smtp_password: "your_smtp_password"
|
||||
smtp_host: "smtp.example.com"
|
||||
smtp_port: 465
|
||||
recipients:
|
||||
- "recipient@example.com"
|
||||
|
||||
traders:
|
||||
- name: "章盟主"
|
||||
alias: "章建平"
|
||||
|
||||
@ -11,6 +11,7 @@ dependencies = [
|
||||
"fastapi>=0.116.1",
|
||||
"uvicorn>=0.35.0",
|
||||
"jinja2>=3.1.6",
|
||||
"reportlab>=4.4.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
56
backend/scripts/daily_report.py
Normal file
56
backend/scripts/daily_report.py
Normal file
@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from _bootstrap import add_src_to_path
|
||||
|
||||
add_src_to_path()
|
||||
|
||||
from lhbfx.config import load_config
|
||||
from lhbfx.mailer import send_email
|
||||
from lhbfx.pdf_export import generate_daily_report_pdf
|
||||
from lhbfx.reporting import (
|
||||
build_daily_report,
|
||||
build_email_body,
|
||||
default_report_output_path,
|
||||
get_latest_trade_date,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate and optionally send lhbfx daily report")
|
||||
parser.add_argument("--trade-date", help="Trade date in YYYY-MM-DD format")
|
||||
parser.add_argument("--send", action="store_true", help="Send email after generating report")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
config = load_config()
|
||||
trade_date = args.trade_date or get_latest_trade_date(config)
|
||||
if not trade_date:
|
||||
raise RuntimeError("No trade date available in database")
|
||||
|
||||
report = build_daily_report(config=config, trade_date=trade_date)
|
||||
pdf_path = default_report_output_path(trade_date)
|
||||
generate_daily_report_pdf(report, pdf_path)
|
||||
body_text = build_email_body(report)
|
||||
|
||||
print(f"Generated PDF: {pdf_path}")
|
||||
|
||||
if args.send:
|
||||
if config.mail is None:
|
||||
raise RuntimeError("Mail config is missing")
|
||||
subject = f"lhbfx 盘后日报 - {trade_date}"
|
||||
send_email(
|
||||
mail_config=config.mail,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
attachments=[pdf_path],
|
||||
)
|
||||
print(f"Email sent to: {', '.join(config.mail.recipients)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -59,6 +59,15 @@ def apply_incremental_alters(config: AppConfig) -> None:
|
||||
cursor.execute(
|
||||
"ALTER TABLE lhb_detail_seats ADD UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)"
|
||||
)
|
||||
if not _index_exists(cursor, schema_name, "lhb_detail_seats", "idx_lhb_detail_trader_stock_date"):
|
||||
cursor.execute(
|
||||
"ALTER TABLE lhb_detail_seats ADD KEY idx_lhb_detail_trader_stock_date (matched_trader_name, stock_code, trade_date)"
|
||||
)
|
||||
|
||||
if not _index_exists(cursor, schema_name, "warning_events", "idx_warning_events_trader_type_date_code"):
|
||||
cursor.execute(
|
||||
"ALTER TABLE warning_events ADD KEY idx_warning_events_trader_type_date_code (trader_name, warning_type, trade_date, stock_code)"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@ -23,6 +23,16 @@ class DatabaseConfig:
|
||||
connect_timeout_seconds: int = 10
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MailConfig:
|
||||
sender_email: str
|
||||
smtp_username: str
|
||||
smtp_password: str
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
recipients: list[str]
|
||||
|
||||
|
||||
class AppConfig:
|
||||
def __init__(self, raw: dict[str, Any], path: Path) -> None:
|
||||
self.raw = raw
|
||||
@ -55,9 +65,22 @@ class AppConfig:
|
||||
def data_sources(self) -> dict[str, Any]:
|
||||
return self.raw.get("data_sources", {})
|
||||
|
||||
@property
|
||||
def mail(self) -> MailConfig | None:
|
||||
mail = self.raw.get("mail")
|
||||
if not mail:
|
||||
return None
|
||||
return MailConfig(
|
||||
sender_email=mail["sender_email"],
|
||||
smtp_username=mail["smtp_username"],
|
||||
smtp_password=mail["smtp_password"],
|
||||
smtp_host=mail["smtp_host"],
|
||||
smtp_port=int(mail["smtp_port"]),
|
||||
recipients=list(mail.get("recipients", [])),
|
||||
)
|
||||
|
||||
|
||||
def load_config(path: str | Path | None = None) -> AppConfig:
|
||||
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
return AppConfig(raw=raw, path=config_path)
|
||||
|
||||
|
||||
37
backend/src/lhbfx/mailer.py
Normal file
37
backend/src/lhbfx/mailer.py
Normal file
@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
from .config import MailConfig
|
||||
|
||||
|
||||
def send_email(
|
||||
*,
|
||||
mail_config: MailConfig,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
attachments: list[Path] | None = None,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = mail_config.sender_email
|
||||
message["To"] = ", ".join(mail_config.recipients)
|
||||
message.set_content(body_text)
|
||||
|
||||
for attachment in attachments or []:
|
||||
mime_type, _ = mimetypes.guess_type(str(attachment))
|
||||
maintype, subtype = (mime_type or "application/octet-stream").split("/", 1)
|
||||
with attachment.open("rb") as file_obj:
|
||||
message.add_attachment(
|
||||
file_obj.read(),
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
filename=attachment.name,
|
||||
)
|
||||
|
||||
with smtplib.SMTP_SSL(mail_config.smtp_host, mail_config.smtp_port) as smtp:
|
||||
smtp.login(mail_config.smtp_username, mail_config.smtp_password)
|
||||
smtp.send_message(message)
|
||||
182
backend/src/lhbfx/pdf_export.py
Normal file
182
backend/src/lhbfx/pdf_export.py
Normal file
@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
from reportlab.pdfbase.pdfmetrics import registerFont
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from .reporting import DailyReport, _major_board_label, _sector_label
|
||||
|
||||
|
||||
WINDOWS_FONT_CANDIDATES = [
|
||||
(r"C:\Windows\Fonts\simhei.ttf", "SimHei"),
|
||||
(r"C:\Windows\Fonts\simfang.ttf", "SimFang"),
|
||||
]
|
||||
|
||||
|
||||
def _register_font() -> str:
|
||||
for font_path, font_name in WINDOWS_FONT_CANDIDATES:
|
||||
if Path(font_path).exists():
|
||||
registerFont(TTFont(font_name, font_path))
|
||||
return font_name
|
||||
registerFont(UnicodeCIDFont("STSong-Light"))
|
||||
return "STSong-Light"
|
||||
|
||||
|
||||
FONT_NAME = _register_font()
|
||||
|
||||
|
||||
def _styles():
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"title": ParagraphStyle(
|
||||
"ReportTitle",
|
||||
parent=base["Title"],
|
||||
fontName=FONT_NAME,
|
||||
fontSize=18,
|
||||
leading=24,
|
||||
),
|
||||
"heading": ParagraphStyle(
|
||||
"ReportHeading",
|
||||
parent=base["Heading2"],
|
||||
fontName=FONT_NAME,
|
||||
fontSize=13,
|
||||
leading=18,
|
||||
spaceAfter=6,
|
||||
),
|
||||
"body": ParagraphStyle(
|
||||
"ReportBody",
|
||||
parent=base["BodyText"],
|
||||
fontName=FONT_NAME,
|
||||
fontSize=10.5,
|
||||
leading=15,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_table(rows: list[list[str]], col_widths: list[float] | None = None) -> Table:
|
||||
table = Table(rows, repeatRows=1, colWidths=col_widths)
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), FONT_NAME),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#d6a85f")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#101721")),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#303846")),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f7f7f7"), colors.white]),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def generate_daily_report_pdf(report: DailyReport, output_path: Path) -> Path:
|
||||
styles = _styles()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc = SimpleDocTemplate(
|
||||
str(output_path),
|
||||
pagesize=A4,
|
||||
leftMargin=15 * mm,
|
||||
rightMargin=15 * mm,
|
||||
topMargin=15 * mm,
|
||||
bottomMargin=15 * mm,
|
||||
)
|
||||
body_style = ParagraphStyle(
|
||||
"WrappedBody",
|
||||
parent=styles["body"],
|
||||
fontName=FONT_NAME,
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
wordWrap="CJK",
|
||||
)
|
||||
|
||||
story = [
|
||||
Paragraph(f"lhbfx 盘后日报 - {report.trade_date}", styles["title"]),
|
||||
Spacer(1, 8),
|
||||
Paragraph(
|
||||
f"关注池股票数:{len(report.watchlist_items)} 关注池流水数:{len(report.watch_actions)} 待加入关注候选数:{len(report.candidate_actions)} 预警数:{len(report.warning_items)}",
|
||||
styles["body"],
|
||||
),
|
||||
Spacer(1, 10),
|
||||
Paragraph("关注池情况", styles["heading"]),
|
||||
]
|
||||
|
||||
watch_rows = [["股票", "游资", "行业名称", "上市板块", "买入(万)", "卖出(万)", "净额(万)", "席位"]]
|
||||
if report.watch_actions:
|
||||
for action in report.watch_actions[:20]:
|
||||
watch_rows.append(
|
||||
[
|
||||
f"{action['stock_name']} {action['stock_code']}",
|
||||
action["trader_name"],
|
||||
_sector_label(action),
|
||||
_major_board_label(action),
|
||||
str(action.get("buy_amount_wan", "-")),
|
||||
str(action.get("sell_amount_wan", "-")),
|
||||
str(action.get("net_amount_wan", "-")),
|
||||
Paragraph(str(action.get("seat_name", "-")), body_style),
|
||||
]
|
||||
)
|
||||
else:
|
||||
watch_rows.append(["无", "-", "-", "-", "-", "-", "-", "-"])
|
||||
watch_col_widths = [
|
||||
28 * mm,
|
||||
18 * mm,
|
||||
33 * mm,
|
||||
18 * mm,
|
||||
18 * mm,
|
||||
18 * mm,
|
||||
18 * mm,
|
||||
52 * mm,
|
||||
]
|
||||
story.append(_build_table(watch_rows, watch_col_widths))
|
||||
story.extend([Spacer(1, 10), Paragraph("今日待加入关注", styles["heading"])])
|
||||
|
||||
candidate_rows = [["股票", "游资", "行业名称", "上市板块", "买入(万)", "卖出(万)", "净额(万)", "股价", "涨跌"]]
|
||||
if report.candidate_actions:
|
||||
for action in report.candidate_actions[:20]:
|
||||
candidate_rows.append(
|
||||
[
|
||||
f"{action['stock_name']} {action['stock_code']}",
|
||||
action["trader_name"],
|
||||
_sector_label(action),
|
||||
_major_board_label(action),
|
||||
str(action.get("buy_amount_wan", "-")),
|
||||
str(action.get("sell_amount_wan", "-")),
|
||||
str(action.get("net_amount_wan", "-")),
|
||||
str(action.get("current_price", "-")),
|
||||
str(action.get("pct_chg", "-")),
|
||||
]
|
||||
)
|
||||
else:
|
||||
candidate_rows.append(["无", "-", "-", "-", "-", "-", "-", "-", "-"])
|
||||
story.append(_build_table(candidate_rows))
|
||||
|
||||
if report.warning_items:
|
||||
story.extend([Spacer(1, 10), Paragraph("风险与预警", styles["heading"])])
|
||||
warning_rows = [["股票", "游资", "类型", "等级", "原因"]]
|
||||
for item in report.warning_items[:20]:
|
||||
warning_rows.append(
|
||||
[
|
||||
f"{item['stock_name']} {item['stock_code']}",
|
||||
str(item.get("trader_name", "-")),
|
||||
str(item.get("warning_type", "-")),
|
||||
str(item.get("warning_level", "-")),
|
||||
str(item.get("trigger_reason", "-")),
|
||||
]
|
||||
)
|
||||
story.append(_build_table(warning_rows))
|
||||
|
||||
doc.build(story)
|
||||
return output_path
|
||||
@ -36,22 +36,22 @@ def _parse_json_list(value: Any) -> list[Any]:
|
||||
|
||||
def _infer_market_label(stock_code: str) -> str:
|
||||
if stock_code.startswith(("6", "9", "5", "688")):
|
||||
return "沪A"
|
||||
return "深A"
|
||||
return "\u6caaA"
|
||||
return "\u6df1A"
|
||||
|
||||
|
||||
def _infer_board_label(stock_code: str) -> str:
|
||||
if stock_code.startswith(("688", "689")):
|
||||
return "绉戝垱鏉?"
|
||||
return "\u79d1\u521b\u677f"
|
||||
if stock_code.startswith(("300", "301")):
|
||||
return "鍒涗笟鏉?"
|
||||
return "\u521b\u4e1a\u677f"
|
||||
if stock_code.startswith(("8", "4", "920")):
|
||||
return "鍖椾氦鎵€"
|
||||
return "\u5317\u4ea4\u6240"
|
||||
if stock_code.startswith(("60", "601", "603", "605", "900")):
|
||||
return "娌富鏉?"
|
||||
return "\u6caa\u4e3b\u677f"
|
||||
if stock_code.startswith(("000", "001", "002", "003", "200")):
|
||||
return "娣变富鏉?"
|
||||
return "A鑲?"
|
||||
return "\u6df1\u4e3b\u677f"
|
||||
return "A\u80a1"
|
||||
|
||||
|
||||
def fetch_summary() -> dict[str, Any]:
|
||||
@ -121,15 +121,29 @@ def fetch_traders() -> list[dict[str, Any]]:
|
||||
t.alias_name,
|
||||
t.warning_weight,
|
||||
t.style_tags,
|
||||
COUNT(DISTINCT d.stock_code) AS stock_count,
|
||||
COUNT(DISTINCT CASE WHEN w.warning_type = 'sell_alert' THEN CONCAT(w.trade_date, ':', w.stock_code) END) AS sell_alert_count,
|
||||
COUNT(DISTINCT CASE WHEN w.warning_type = 'slow_exit_watch' THEN CONCAT(w.trade_date, ':', w.stock_code) END) AS slow_exit_count
|
||||
COALESCE(ds.stock_count, 0) AS stock_count,
|
||||
COALESCE(ws.sell_alert_count, 0) AS sell_alert_count,
|
||||
COALESCE(ws.slow_exit_count, 0) AS slow_exit_count
|
||||
FROM traders t
|
||||
LEFT JOIN lhb_detail_seats d
|
||||
ON d.matched_trader_name = t.name
|
||||
LEFT JOIN warning_events w
|
||||
ON w.trader_name = t.name
|
||||
GROUP BY t.id, t.name, t.alias_name, t.warning_weight, t.style_tags
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
matched_trader_name,
|
||||
COUNT(DISTINCT stock_code) AS stock_count
|
||||
FROM lhb_detail_seats
|
||||
WHERE matched_trader_name IS NOT NULL
|
||||
GROUP BY matched_trader_name
|
||||
) ds
|
||||
ON ds.matched_trader_name = t.name
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
trader_name,
|
||||
COUNT(DISTINCT CASE WHEN warning_type = 'sell_alert' THEN CONCAT(trade_date, ':', stock_code) END) AS sell_alert_count,
|
||||
COUNT(DISTINCT CASE WHEN warning_type = 'slow_exit_watch' THEN CONCAT(trade_date, ':', stock_code) END) AS slow_exit_count
|
||||
FROM warning_events
|
||||
WHERE trader_name IS NOT NULL
|
||||
GROUP BY trader_name
|
||||
) ws
|
||||
ON ws.trader_name = t.name
|
||||
ORDER BY stock_count DESC, t.name
|
||||
"""
|
||||
)
|
||||
@ -274,6 +288,7 @@ def fetch_trader_actions(
|
||||
o.price AS current_price,
|
||||
o.pct_chg,
|
||||
s.industry,
|
||||
s.concept_tags,
|
||||
s.market,
|
||||
s.total_market_value,
|
||||
s.circulating_market_value,
|
||||
@ -313,6 +328,7 @@ def fetch_trader_actions(
|
||||
actions = [_normalize_row(row) for row in cursor.fetchall()]
|
||||
|
||||
for action in actions:
|
||||
action["concept_tags"] = _parse_json_list(action.get("concept_tags"))
|
||||
action["market"] = action.get("market") or _infer_market_label(action["stock_code"])
|
||||
action["board_label"] = _infer_board_label(action["stock_code"])
|
||||
|
||||
@ -419,65 +435,6 @@ def fetch_trader_detail(trader_id: int) -> dict[str, Any]:
|
||||
for stock in stocks:
|
||||
stock["is_net_amount_increasing"] = increasing_by_stock.get(stock["stock_code"], False)
|
||||
|
||||
missing_codes = [
|
||||
row["stock_code"]
|
||||
for row in stocks[:30]
|
||||
if not row.get("industry") or not row.get("market") or row.get("total_market_value") is None
|
||||
]
|
||||
if missing_codes:
|
||||
eastmoney = EastMoneyClient()
|
||||
seen_codes: set[str] = set()
|
||||
for stock_code in missing_codes:
|
||||
if stock_code in seen_codes:
|
||||
continue
|
||||
seen_codes.add(stock_code)
|
||||
profile: dict[str, Any] = {}
|
||||
snapshot: dict[str, Any] = {}
|
||||
try:
|
||||
profile = eastmoney.fetch_company_profile(stock_code)
|
||||
except Exception:
|
||||
profile = {}
|
||||
try:
|
||||
snapshot = eastmoney.fetch_quote_snapshot(stock_code)
|
||||
except Exception:
|
||||
snapshot = {}
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO stocks (
|
||||
stock_code,
|
||||
stock_name,
|
||||
market,
|
||||
industry,
|
||||
concept_tags,
|
||||
total_market_value,
|
||||
circulating_market_value
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
stock_name = COALESCE(NULLIF(VALUES(stock_name), ''), stock_name),
|
||||
market = COALESCE(NULLIF(VALUES(market), ''), market),
|
||||
industry = COALESCE(NULLIF(VALUES(industry), ''), industry),
|
||||
concept_tags = COALESCE(VALUES(concept_tags), concept_tags),
|
||||
total_market_value = COALESCE(VALUES(total_market_value), total_market_value),
|
||||
circulating_market_value = COALESCE(VALUES(circulating_market_value), circulating_market_value)
|
||||
""",
|
||||
(
|
||||
stock_code,
|
||||
profile.get("stock_name") or (snapshot.get("stock_name") if snapshot else None) or stock_code,
|
||||
profile.get("market"),
|
||||
profile.get("industry") or snapshot.get("industry"),
|
||||
json.dumps(profile.get("concept_tags") or [], ensure_ascii=False) if profile.get("concept_tags") else None,
|
||||
snapshot.get("total_market_value"),
|
||||
snapshot.get("circulating_market_value"),
|
||||
),
|
||||
)
|
||||
|
||||
cursor.execute(stock_query, (trader_name,))
|
||||
stocks = [_normalize_row(row) for row in cursor.fetchall()]
|
||||
for stock in stocks:
|
||||
stock["is_net_amount_increasing"] = increasing_by_stock.get(stock["stock_code"], False)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT trade_date, stock_code, stock_name, warning_type, warning_level, trigger_reason
|
||||
|
||||
254
backend/src/lhbfx/reporting.py
Normal file
254
backend/src/lhbfx/reporting.py
Normal file
@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import AppConfig
|
||||
from .db import db_cursor
|
||||
from .queries import fetch_trader_actions, fetch_watchlist, fetch_warnings
|
||||
from .sources.eastmoney import EastMoneyClient
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DailyReport:
|
||||
trade_date: str
|
||||
watchlist_items: list[dict[str, Any]]
|
||||
watch_actions: list[dict[str, Any]]
|
||||
candidate_actions: list[dict[str, Any]]
|
||||
warning_items: list[dict[str, Any]]
|
||||
|
||||
|
||||
def get_latest_trade_date(config: AppConfig) -> str | None:
|
||||
with db_cursor(config=config) as (_, cursor):
|
||||
cursor.execute("SELECT MAX(trade_date) AS latest_trade_date FROM lhb_overview")
|
||||
row = cursor.fetchone()
|
||||
latest_trade_date = row["latest_trade_date"] if row else None
|
||||
return latest_trade_date.isoformat() if latest_trade_date else None
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float:
|
||||
if value in (None, "", "-"):
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _normalize_seat_name(value: str) -> str:
|
||||
return "".join(value.split()).strip()
|
||||
|
||||
|
||||
def _merged_action_side(buy_amount: float, sell_amount: float, net_amount: float) -> str:
|
||||
if buy_amount > 0 and sell_amount <= 0:
|
||||
return "buy"
|
||||
if sell_amount > 0 and buy_amount <= 0:
|
||||
return "sell"
|
||||
if net_amount >= 0:
|
||||
return "net_buy"
|
||||
return "net_sell"
|
||||
|
||||
|
||||
def aggregate_watch_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for action in actions:
|
||||
key = "::".join(
|
||||
[
|
||||
action["stock_code"],
|
||||
action["trade_date"],
|
||||
action["trader_name"],
|
||||
_normalize_seat_name(action["seat_name"]),
|
||||
]
|
||||
)
|
||||
existing = groups.get(key)
|
||||
if existing is None:
|
||||
groups[key] = dict(action)
|
||||
continue
|
||||
|
||||
next_buy = _to_float(existing.get("buy_amount_wan")) + _to_float(action.get("buy_amount_wan"))
|
||||
next_sell = _to_float(existing.get("sell_amount_wan")) + _to_float(action.get("sell_amount_wan"))
|
||||
next_net = next_buy - next_sell
|
||||
table_titles = [existing.get("table_title", ""), action.get("table_title", "")]
|
||||
merged_titles = " / ".join(dict.fromkeys(title for title in table_titles if title))
|
||||
|
||||
existing.update(
|
||||
{
|
||||
"table_title": merged_titles,
|
||||
"buy_amount_wan": f"{next_buy:.2f}",
|
||||
"sell_amount_wan": f"{next_sell:.2f}",
|
||||
"net_amount_wan": f"{next_net:.2f}",
|
||||
"action_side": _merged_action_side(next_buy, next_sell, next_net),
|
||||
}
|
||||
)
|
||||
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def unique_candidate_actions(actions: list[dict[str, Any]], watched_codes: set[str]) -> list[dict[str, Any]]:
|
||||
unique: dict[str, dict[str, Any]] = {}
|
||||
for action in actions:
|
||||
if action["stock_code"] in watched_codes:
|
||||
continue
|
||||
unique.setdefault(action["stock_code"], action)
|
||||
return list(unique.values())
|
||||
|
||||
|
||||
def _sector_label(action: dict[str, Any]) -> str:
|
||||
concept_tags = action.get("concept_tags") or []
|
||||
if isinstance(concept_tags, str):
|
||||
try:
|
||||
concept_tags = json.loads(concept_tags)
|
||||
except json.JSONDecodeError:
|
||||
concept_tags = []
|
||||
if concept_tags:
|
||||
return " / ".join(concept_tags[:2])
|
||||
return str(action.get("industry") or action.get("board_label") or "-")
|
||||
|
||||
|
||||
def _major_board_label(action: dict[str, Any]) -> str:
|
||||
return str(action.get("board_label") or action.get("market") or "-")
|
||||
|
||||
|
||||
def enrich_stock_metadata(*, config: AppConfig, stock_codes: set[str]) -> None:
|
||||
if not stock_codes:
|
||||
return
|
||||
|
||||
placeholders = ", ".join(["%s"] * len(stock_codes))
|
||||
with db_cursor(config=config) as (_, cursor):
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT stock_code, stock_name, industry, concept_tags, market
|
||||
FROM stocks
|
||||
WHERE stock_code IN ({placeholders})
|
||||
""",
|
||||
tuple(sorted(stock_codes)),
|
||||
)
|
||||
existing_rows = {row["stock_code"]: row for row in cursor.fetchall()}
|
||||
|
||||
client = EastMoneyClient()
|
||||
for stock_code in sorted(stock_codes):
|
||||
row = existing_rows.get(stock_code)
|
||||
has_industry = bool(row and row.get("industry"))
|
||||
has_concepts = bool(row and row.get("concept_tags"))
|
||||
if has_industry and has_concepts:
|
||||
continue
|
||||
|
||||
profile = client.fetch_company_profile(stock_code)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO stocks (stock_code, stock_name, market, industry, concept_tags)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
stock_name = COALESCE(NULLIF(VALUES(stock_name), ''), stock_name),
|
||||
market = COALESCE(NULLIF(VALUES(market), ''), market),
|
||||
industry = COALESCE(NULLIF(VALUES(industry), ''), industry),
|
||||
concept_tags = COALESCE(VALUES(concept_tags), concept_tags)
|
||||
""",
|
||||
(
|
||||
stock_code,
|
||||
profile.get("stock_name") or (row.get("stock_name") if row else stock_code),
|
||||
profile.get("market"),
|
||||
profile.get("industry"),
|
||||
json.dumps(profile.get("concept_tags") or [], ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_daily_report(*, config: AppConfig, trade_date: str) -> DailyReport:
|
||||
actions_response = fetch_trader_actions(
|
||||
date_from=trade_date,
|
||||
date_to=trade_date,
|
||||
limit=500,
|
||||
)
|
||||
initial_actions = actions_response["actions"]
|
||||
enrich_stock_metadata(
|
||||
config=config,
|
||||
stock_codes={action["stock_code"] for action in initial_actions},
|
||||
)
|
||||
all_actions = fetch_trader_actions(
|
||||
date_from=trade_date,
|
||||
date_to=trade_date,
|
||||
limit=500,
|
||||
)["actions"]
|
||||
watchlist_items = fetch_watchlist()
|
||||
watch_codes = {item["stock_code"] for item in watchlist_items}
|
||||
watch_actions_raw = [action for action in all_actions if action["stock_code"] in watch_codes]
|
||||
watch_actions = aggregate_watch_actions(watch_actions_raw)
|
||||
candidate_actions = unique_candidate_actions(all_actions, watch_codes)
|
||||
warning_items = [
|
||||
warning
|
||||
for warning in fetch_warnings(limit=100)
|
||||
if warning.get("stock_code") in watch_codes
|
||||
]
|
||||
|
||||
return DailyReport(
|
||||
trade_date=trade_date,
|
||||
watchlist_items=watchlist_items,
|
||||
watch_actions=watch_actions,
|
||||
candidate_actions=candidate_actions,
|
||||
warning_items=warning_items,
|
||||
)
|
||||
|
||||
|
||||
def build_email_body(report: DailyReport) -> str:
|
||||
lines = [
|
||||
f"lhbfx 盘后日报 - {report.trade_date}",
|
||||
"",
|
||||
f"关注池股票数:{len(report.watchlist_items)}",
|
||||
f"关注池当日流水数:{len(report.watch_actions)}",
|
||||
f"待加入关注候选数:{len(report.candidate_actions)}",
|
||||
f"风险预警数:{len(report.warning_items)}",
|
||||
"",
|
||||
"关注池情况:",
|
||||
]
|
||||
|
||||
if report.watch_actions:
|
||||
for action in report.watch_actions[:10]:
|
||||
lines.append(
|
||||
f"- {action['stock_name']} {action['stock_code']} | {action['trader_name']} | "
|
||||
f"行业名称 {_sector_label(action)} | "
|
||||
f"上市板块 {_major_board_label(action)} | "
|
||||
f"买入 {action.get('buy_amount_wan', '-')}万 | "
|
||||
f"卖出 {action.get('sell_amount_wan', '-')}万 | "
|
||||
f"净额 {action.get('net_amount_wan', '-')}万"
|
||||
)
|
||||
else:
|
||||
lines.append("- 今日关注池暂无新增流水")
|
||||
|
||||
lines.extend(["", "今日待加入关注:"])
|
||||
|
||||
if report.candidate_actions:
|
||||
for action in report.candidate_actions[:10]:
|
||||
lines.append(
|
||||
f"- {action['stock_name']} {action['stock_code']} | {action['trader_name']} | "
|
||||
f"行业名称 {_sector_label(action)} | "
|
||||
f"上市板块 {_major_board_label(action)} | "
|
||||
f"买入 {action.get('buy_amount_wan', '-')}万 | "
|
||||
f"卖出 {action.get('sell_amount_wan', '-')}万 | "
|
||||
f"净额 {action.get('net_amount_wan', '-')}万 | "
|
||||
f"股价 {action.get('current_price', '-')} | "
|
||||
f"涨跌 {action.get('pct_chg', '-')}"
|
||||
)
|
||||
else:
|
||||
lines.append("- 今日暂无候选股票")
|
||||
|
||||
if report.warning_items:
|
||||
lines.extend(["", "关注池风险提示:"])
|
||||
for warning in report.warning_items[:10]:
|
||||
lines.append(
|
||||
f"- {warning['stock_name']} {warning['stock_code']} | {warning['warning_type']} | {warning['trigger_reason']}"
|
||||
)
|
||||
|
||||
lines.extend(["", "详见附件 PDF 日报。"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def default_report_output_path(trade_date: str) -> Path:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
output_dir = root.parent / "output" / "reports"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir / f"lhbfx-daily-report-{trade_date}.pdf"
|
||||
@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS lhb_detail_seats (
|
||||
KEY idx_lhb_detail_code (stock_code),
|
||||
KEY idx_lhb_detail_trade_date (trade_date),
|
||||
KEY idx_lhb_detail_trader (matched_trader_name),
|
||||
KEY idx_lhb_detail_trader_stock_date (matched_trader_name, stock_code, trade_date),
|
||||
UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)
|
||||
);
|
||||
|
||||
@ -94,7 +95,8 @@ CREATE TABLE IF NOT EXISTS warning_events (
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_warning_events_code (stock_code),
|
||||
KEY idx_warning_events_trade_date (trade_date),
|
||||
KEY idx_warning_events_trader (trader_name)
|
||||
KEY idx_warning_events_trader (trader_name),
|
||||
KEY idx_warning_events_trader_type_date_code (trader_name, warning_type, trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watchlist_entries (
|
||||
|
||||
@ -21,7 +21,53 @@ def infer_secid(stock_code: str) -> str:
|
||||
return f"0.{stock_code}"
|
||||
|
||||
|
||||
def infer_symbol(stock_code: str) -> str:
|
||||
if stock_code.startswith(("6", "9", "5", "688")):
|
||||
return f"SH{stock_code}"
|
||||
if stock_code.startswith(("8", "4")):
|
||||
return f"BJ{stock_code}"
|
||||
return f"SZ{stock_code}"
|
||||
|
||||
|
||||
class EastMoneyClient:
|
||||
def fetch_company_profile(self, stock_code: str) -> dict[str, Any]:
|
||||
symbol = infer_symbol(stock_code)
|
||||
survey_url = "https://emweb.securities.eastmoney.com/PC_HSF10/CompanySurvey/CompanySurveyAjax"
|
||||
concept_url = "https://emweb.securities.eastmoney.com/PC_HSF10/CoreConception/PageAjax"
|
||||
|
||||
survey_response = requests.get(
|
||||
survey_url,
|
||||
params={"code": symbol},
|
||||
headers=DEFAULT_HEADERS,
|
||||
timeout=20,
|
||||
)
|
||||
survey_response.raise_for_status()
|
||||
survey_payload = survey_response.json() or {}
|
||||
basic = survey_payload.get("jbzl") or {}
|
||||
|
||||
concept_response = requests.get(
|
||||
concept_url,
|
||||
params={"code": symbol},
|
||||
headers=DEFAULT_HEADERS,
|
||||
timeout=20,
|
||||
)
|
||||
concept_response.raise_for_status()
|
||||
concept_payload = concept_response.json() or {}
|
||||
concept_rows = concept_payload.get("ssbk") or []
|
||||
concept_tags = []
|
||||
for row in concept_rows:
|
||||
board_name = row.get("BOARD_NAME")
|
||||
if board_name and board_name not in concept_tags:
|
||||
concept_tags.append(board_name)
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": basic.get("agjc"),
|
||||
"market": basic.get("zqlb"),
|
||||
"industry": basic.get("sshy") or basic.get("sszjhhy"),
|
||||
"concept_tags": concept_tags,
|
||||
}
|
||||
|
||||
def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]:
|
||||
secid = infer_secid(stock_code)
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
|
||||
93
docs/技术文档.md
93
docs/技术文档.md
@ -245,3 +245,96 @@ npm run dev -- --host 127.0.0.1 --port 5173
|
||||
- 历史文档与部分配置存在中文乱码
|
||||
- 部分来源数据原始字段编码不稳定
|
||||
- 页面样式近期经历多轮快速调整,仍建议补视觉回归测试
|
||||
|
||||
## 9. 定时更新与邮件报送方案
|
||||
|
||||
为满足“每天下午 17:00 自动更新并发送盘后邮件”的新增需求,建议增加一个独立的调度与报送模块。
|
||||
|
||||
### 9.1 调度方式
|
||||
|
||||
建议采用以下任一方式:
|
||||
|
||||
- 服务器 `cron`
|
||||
- Windows 任务计划程序
|
||||
- 后续统一接入独立任务调度器
|
||||
|
||||
默认调度时间:
|
||||
|
||||
- 每天下午 `17:00`
|
||||
|
||||
### 9.2 推荐执行流程
|
||||
|
||||
每日任务执行顺序建议如下:
|
||||
|
||||
1. 拉取当日龙虎榜与行情数据
|
||||
2. 更新数据库
|
||||
3. 重新生成预警数据
|
||||
4. 统计关注池情况
|
||||
5. 统计待加入关注候选列表
|
||||
6. 生成 PDF 日报
|
||||
7. 发送邮件正文与附件
|
||||
8. 记录执行日志
|
||||
|
||||
### 9.3 建议新增模块
|
||||
|
||||
建议新增以下能力:
|
||||
|
||||
- `backend/scripts/daily_report.py`
|
||||
- 串联数据更新、统计、PDF 生成、邮件发送
|
||||
- `backend/src/lhbfx/reporting.py`
|
||||
- 负责报表数据整理
|
||||
- `backend/src/lhbfx/mailer.py`
|
||||
- 负责 SMTP 或邮件服务发送
|
||||
- `backend/src/lhbfx/pdf_export.py`
|
||||
- 负责 PDF 生成
|
||||
|
||||
### 9.4 配置项建议
|
||||
|
||||
建议在配置文件中新增:
|
||||
|
||||
- 是否启用邮件报送
|
||||
- SMTP 主机
|
||||
- SMTP 端口
|
||||
- 发件人账号
|
||||
- 发件人密码或授权码
|
||||
- 收件人列表
|
||||
- 抄送列表
|
||||
- 日报输出目录
|
||||
- 调度时间
|
||||
|
||||
### 9.5 PDF 生成建议
|
||||
|
||||
PDF 附件可以通过以下方式生成:
|
||||
|
||||
- 基于 HTML 模板渲染后导出 PDF
|
||||
- 或直接使用 Python PDF 库生成结构化报告
|
||||
|
||||
推荐内容结构:
|
||||
|
||||
1. 标题页
|
||||
2. 数据更新时间
|
||||
3. 关注池总览
|
||||
4. 关注池流水摘要
|
||||
5. 今日待加入关注列表
|
||||
6. 风险与预警摘要
|
||||
|
||||
### 9.6 邮件正文建议
|
||||
|
||||
正文采用简洁摘要形式,便于手机查看:
|
||||
|
||||
- 今日关注池概况
|
||||
- 今日关注池重点动作
|
||||
- 今日待加入关注候选
|
||||
- 风险提示
|
||||
- 附件说明
|
||||
|
||||
### 9.7 测试建议
|
||||
|
||||
新增该需求后,应补充以下验证:
|
||||
|
||||
- 17:00 定时任务是否被正确触发
|
||||
- 更新失败时是否生成错误日志
|
||||
- PDF 是否成功生成
|
||||
- 邮件正文是否包含关键摘要
|
||||
- 附件是否能正常打开
|
||||
- 多收件人场景是否发送成功
|
||||
|
||||
43
docs/需求文档.md
43
docs/需求文档.md
@ -154,7 +154,48 @@
|
||||
- 关键页面在 1440px 及以上宽度下保持清晰稳定
|
||||
- 文档、配置与代码要支持团队继续接手迭代
|
||||
|
||||
## 8. 后续建议
|
||||
## 8. 新增定时报送需求
|
||||
|
||||
新增一项每日自动化需求:
|
||||
|
||||
- 每个交易日下午 17:00 自动更新最新龙虎榜与相关统计数据
|
||||
- 更新完成后自动统计“关注池情况”和“今日待加入关注列表”
|
||||
- 自动发送邮件给指定收件人
|
||||
- 邮件需同时包含正文摘要和 PDF 附件
|
||||
|
||||
### 8.1 定时更新要求
|
||||
|
||||
- 默认执行时间为每天下午 `17:00`
|
||||
- 若当天为非交易日或数据源尚未更新,需要在邮件正文中明确说明
|
||||
- 若更新失败,需要输出失败原因并进入告警状态
|
||||
|
||||
### 8.2 邮件正文要求
|
||||
|
||||
邮件正文至少包含以下内容:
|
||||
|
||||
- 数据统计日期
|
||||
- 关注池股票数量
|
||||
- 关注池中今日有动作的股票列表
|
||||
- 今日待加入关注列表
|
||||
- 关键风险提示或卖出预警摘要
|
||||
|
||||
### 8.3 PDF 附件要求
|
||||
|
||||
PDF 附件建议作为“盘后日报”输出,至少包含:
|
||||
|
||||
- 当日数据概览
|
||||
- 关注池汇总
|
||||
- 关注池操作流水摘要
|
||||
- 今日待加入关注候选列表
|
||||
- 重点风险与预警说明
|
||||
|
||||
### 8.4 邮件收件要求
|
||||
|
||||
- 支持配置一个或多个收件人
|
||||
- 邮件主题中应包含日期,例如:`lhbfx 盘后日报 - 2026-04-17`
|
||||
- 邮件发送成功与失败都需要记录日志
|
||||
|
||||
## 9. 后续建议
|
||||
|
||||
后续可以继续迭代:
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>顶级游资监控系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
12
frontend/public/favicon.svg
Normal file
12
frontend/public/favicon.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="128" height="128" rx="26" fill="#101721"/>
|
||||
<rect x="14" y="14" width="100" height="100" rx="22" fill="url(#paint0_linear)"/>
|
||||
<path d="M30 84L47 55L61 70L82 38L98 84H87L78 60L61 83L47 67L39 84H30Z" fill="#F5EFE4"/>
|
||||
<circle cx="90" cy="40" r="7" fill="#FF5D5D"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="14" y1="14" x2="114" y2="114" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1A2330"/>
|
||||
<stop offset="1" stop-color="#0D1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppHero from './components/AppHero.vue'
|
||||
import HomeControlScreen from './components/HomeControlScreen.vue'
|
||||
@ -33,12 +33,25 @@ function syncPageFromHash() {
|
||||
currentPage.value = pageFromHash()
|
||||
}
|
||||
|
||||
async function ensurePageData(page: PageKey) {
|
||||
if (dashboard.isBooting.value) return
|
||||
|
||||
if (page === 'trader' && dashboard.selectedTraderId.value !== null) {
|
||||
await dashboard.selectTrader(dashboard.selectedTraderId.value)
|
||||
}
|
||||
|
||||
if (page === 'stock' && dashboard.selectedStockCode.value) {
|
||||
await dashboard.selectStock(dashboard.selectedStockCode.value)
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(page: PageKey) {
|
||||
const nextHash = `#/${page}`
|
||||
if (window.location.hash !== nextHash) {
|
||||
window.location.hash = nextHash
|
||||
}
|
||||
currentPage.value = page
|
||||
void ensurePageData(page)
|
||||
}
|
||||
|
||||
async function handleSelectTrader(traderId: number) {
|
||||
@ -84,12 +97,19 @@ async function handleUnfollowStock(stockCode: string) {
|
||||
onMounted(() => {
|
||||
syncPageFromHash()
|
||||
window.addEventListener('hashchange', syncPageFromHash)
|
||||
void dashboard.initialize()
|
||||
void (async () => {
|
||||
await dashboard.initialize()
|
||||
await ensurePageData(currentPage.value)
|
||||
})()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hashchange', syncPageFromHash)
|
||||
})
|
||||
|
||||
watch(currentPage, (page) => {
|
||||
void ensurePageData(page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -172,11 +172,19 @@ export function useDashboardData() {
|
||||
}
|
||||
|
||||
async function selectTrader(traderId: number) {
|
||||
if (traderDetail.value?.trader.id === traderId) {
|
||||
selectedTraderId.value = traderId
|
||||
return
|
||||
}
|
||||
selectedTraderId.value = traderId
|
||||
traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`)
|
||||
}
|
||||
|
||||
async function selectStock(stockCode: string) {
|
||||
if (stockDetail.value?.stock.stock_code === stockCode) {
|
||||
selectedStockCode.value = stockCode
|
||||
return
|
||||
}
|
||||
selectedStockCode.value = stockCode
|
||||
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
||||
}
|
||||
@ -218,13 +226,13 @@ export function useDashboardData() {
|
||||
await loadActions()
|
||||
|
||||
if (traderResult[0]) {
|
||||
await selectTrader(traderResult[0].id)
|
||||
selectedTraderId.value = traderResult[0].id
|
||||
}
|
||||
|
||||
const preferredStockCode = watchlist.value[0]?.stock_code ?? warningResult[0]?.stock_code
|
||||
if (preferredStockCode) {
|
||||
selectedWarningCode.value = preferredStockCode
|
||||
await selectStock(preferredStockCode)
|
||||
selectedStockCode.value = preferredStockCode
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = String(error instanceof Error ? error.message : error)
|
||||
|
||||
Reference in New Issue
Block a user