Compare commits
5 Commits
5a5dd3c9fd
...
001ef0dfde
| Author | SHA1 | Date | |
|---|---|---|---|
| 001ef0dfde | |||
| 36fda633df | |||
| dc205c5f1b | |||
| 8c9117ca4c | |||
| d661b801df |
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,9 +24,6 @@ frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/.vite-temp/
|
||||
|
||||
# Sensitive or machine-local config
|
||||
backend/config.yaml
|
||||
|
||||
# Temp / debug files
|
||||
_tmp_*.json
|
||||
_curl_*.json
|
||||
|
||||
28
README.md
28
README.md
@ -23,6 +23,21 @@
|
||||
- 关注池写入数据库,支持新增和删除。
|
||||
- 个股详情支持 K 线、MA5、买卖点与预警信息展示。
|
||||
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
||||
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
|
||||
|
||||
## 最近界面与数据调整
|
||||
|
||||
- 个股详情页的“买卖明细”已改为“买卖力度趋势”图:
|
||||
- 柱形图按日期展示买入和卖出,买入为正、卖出为负。
|
||||
- 折线展示“当日净额”和“累计净额”。
|
||||
- 明细仅在鼠标悬浮图表时显示。
|
||||
- 个股详情页顶部新增“预警列表”按钮:
|
||||
- 有预警时红色高亮提示。
|
||||
- 点击后以弹层方式展示当前个股预警,不再挤占右侧图表区域。
|
||||
- 个股详情、首页候选股、游资详情页的数据补全逻辑已增强:
|
||||
- 优先读取数据库中的股票元数据。
|
||||
- 外部快照失败时增加备用行情源兜底。
|
||||
- 收盘后更新流程会同步补全行业、市值、流通市值等字段。
|
||||
|
||||
## 环境要求
|
||||
|
||||
@ -35,6 +50,7 @@
|
||||
1. 复制配置文件:
|
||||
- 将 `backend/config.example.yaml` 复制为 `backend/config.yaml`
|
||||
- 按实际数据库连接信息修改
|
||||
- 当前仓库已允许提交 `backend/config.yaml`,服务器部署默认读取该文件
|
||||
2. 初始化数据库:
|
||||
|
||||
```powershell
|
||||
@ -47,6 +63,18 @@ python backend/scripts/init_db.py
|
||||
python backend/scripts/run_api.py
|
||||
```
|
||||
|
||||
4. 收盘后更新并发送邮件:
|
||||
|
||||
```powershell
|
||||
python backend/scripts/after_market_update.py --trade-date 2026-04-17 --send-email
|
||||
```
|
||||
|
||||
默认约定:
|
||||
|
||||
- 按 `Asia/Shanghai` 取当天日期
|
||||
- 预期定时为 A 股交易日 `17:00`
|
||||
- 只有当日成功拉到新的龙虎榜数据,才会继续生成预警、PDF 并发邮件;否则自动跳过
|
||||
|
||||
默认地址:
|
||||
|
||||
- `http://127.0.0.1:8000`
|
||||
|
||||
@ -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: "章建平"
|
||||
|
||||
132
backend/config.yaml
Normal file
132
backend/config.yaml
Normal file
@ -0,0 +1,132 @@
|
||||
app:
|
||||
name: "lhbfx"
|
||||
timezone: "Asia/Shanghai"
|
||||
environment: "local"
|
||||
|
||||
database:
|
||||
driver: "mysql"
|
||||
host: "152.136.100.182"
|
||||
port: 3306
|
||||
username: "root"
|
||||
password: "4a3986024e6662f9e571782ece1587298291d18925b44f1f"
|
||||
database: "lhbfx"
|
||||
charset: "utf8mb4"
|
||||
pool_size: 10
|
||||
connect_timeout_seconds: 10
|
||||
|
||||
data_sources:
|
||||
lhb_priority:
|
||||
- "tonghuashun"
|
||||
quote_priority:
|
||||
- "tonghuashun"
|
||||
fundamentals_priority:
|
||||
- "tonghuashun"
|
||||
- "akshare"
|
||||
|
||||
monitoring:
|
||||
target_year: 2026
|
||||
warning_days_without_action: 4
|
||||
max_position_ratio: 0.10
|
||||
fib_levels:
|
||||
- 0.382
|
||||
- 0.5
|
||||
- 0.618
|
||||
moving_average_days:
|
||||
- 5
|
||||
turnover_warning_threshold: 0.30
|
||||
|
||||
mail:
|
||||
sender_email: "1807754808@qq.com"
|
||||
smtp_username: "1807754808@qq.com"
|
||||
smtp_password: "hfyltsqfbmvzegfe"
|
||||
smtp_host: "smtp.qq.com"
|
||||
smtp_port: 465
|
||||
recipients:
|
||||
- "1807754808@qq.com"
|
||||
|
||||
traders:
|
||||
- name: "章盟主"
|
||||
alias: "章建平"
|
||||
style_tags:
|
||||
- "板块中军"
|
||||
- "权重大票"
|
||||
- "趋势强化"
|
||||
- "上亿级别资金体量"
|
||||
warning_weight: "high"
|
||||
match_keywords:
|
||||
- "上海长宁区江苏路"
|
||||
- "上海浦东新区海阳西路"
|
||||
- "宁波彩虹北路"
|
||||
- "宁波广福街"
|
||||
- "杭州延安路"
|
||||
- "杭州富春路"
|
||||
- "杭州四季路"
|
||||
- "杭州宜春路"
|
||||
- "杭州解放东路"
|
||||
seats:
|
||||
core:
|
||||
- "国泰君安上海江苏路"
|
||||
active:
|
||||
- "国泰君安上海海阳西路"
|
||||
- "国泰君安宁波彩虹北路"
|
||||
- "国泰君安宁波广福街"
|
||||
- "中信证券杭州延安路"
|
||||
- "中信证券杭州富春路"
|
||||
- "中信证券杭州四季路"
|
||||
- "中信证券杭州宜春路"
|
||||
- "财通证券杭州解放东路"
|
||||
history:
|
||||
- "国信证券杭州保俶路"
|
||||
- "新疆证券杭州庆春路"
|
||||
- "中信金通证券杭州延安路"
|
||||
- "义乌化工路"
|
||||
|
||||
- name: "炒股养家"
|
||||
alias: ""
|
||||
style_tags:
|
||||
- "情绪周期"
|
||||
- "题材确认"
|
||||
- "高辨识度龙头"
|
||||
warning_weight: "medium"
|
||||
match_keywords:
|
||||
- "上海宛平南路"
|
||||
- "上海茅台路"
|
||||
- "华鑫证券有限责任公司上海分公司"
|
||||
seats:
|
||||
core:
|
||||
- "华鑫证券上海宛平南路"
|
||||
active:
|
||||
- "华鑫证券上海茅台路"
|
||||
- "华鑫证券上海分公司"
|
||||
history: []
|
||||
|
||||
- name: "欢乐海岸"
|
||||
alias: ""
|
||||
style_tags:
|
||||
- "高位强势股"
|
||||
- "妖股"
|
||||
- "封板后锁仓"
|
||||
- "高溢价品牌效应"
|
||||
warning_weight: "high"
|
||||
match_keywords:
|
||||
- "中信证券股份有限公司深圳总部"
|
||||
- "中信证券股份有限公司深圳后海证券营业部"
|
||||
- "广发证券股份有限公司深圳福华一路证券营业部"
|
||||
- "华泰证券股份有限公司深圳分公司"
|
||||
- "华泰证券股份有限公司深圳科苑南路华润大厦证券营业部"
|
||||
- "中国中金财富证券有限公司云浮新兴东堤北路证券营业部"
|
||||
- "天府证券有限责任公司深圳福田金田路证券营业部"
|
||||
- "中泰证券股份有限公司深圳宝源南路证券营业部"
|
||||
seats:
|
||||
core:
|
||||
- "中信证券深圳总部"
|
||||
- "中信证券深圳后海"
|
||||
active:
|
||||
- "广发证券深圳光彩路"
|
||||
- "中信证券深圳分公司"
|
||||
- "华泰证券深圳分公司"
|
||||
- "华泰证券深圳科苑南路华润大厦证券营业部"
|
||||
- "中国中金财富证券云浮新兴东堤北路证券营业部"
|
||||
- "天府证券深圳福田金田路证券营业部"
|
||||
- "中泰证券深圳宝源南路证券营业部"
|
||||
history: []
|
||||
@ -11,6 +11,7 @@ dependencies = [
|
||||
"fastapi>=0.116.1",
|
||||
"uvicorn>=0.35.0",
|
||||
"jinja2>=3.1.6",
|
||||
"reportlab>=4.4.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
116
backend/scripts/after_market_update.py
Normal file
116
backend/scripts/after_market_update.py
Normal file
@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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.pipeline import generate_warnings, import_daily, rematch_traders
|
||||
from lhbfx.reporting import build_daily_report, build_email_body, default_report_output_path
|
||||
|
||||
|
||||
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run lhbfx after-market update workflow")
|
||||
parser.add_argument("--trade-date", help="Trade date in YYYY-MM-DD format, defaults to Asia/Shanghai today")
|
||||
parser.add_argument("--send-email", action="store_true", help="Send completion email after report generation")
|
||||
parser.add_argument("--force", action="store_true", help="Run even if the target date is not a weekday")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def default_trade_date() -> str:
|
||||
return datetime.now(SHANGHAI_TZ).date().isoformat()
|
||||
|
||||
|
||||
def is_weekday(trade_date: str) -> bool:
|
||||
return datetime.fromisoformat(trade_date).weekday() < 5
|
||||
|
||||
|
||||
def build_update_email_body(
|
||||
*,
|
||||
trade_date: str,
|
||||
overview_count: int,
|
||||
detail_count: int,
|
||||
rematch_updated: int,
|
||||
warning_total: int,
|
||||
report_body: str,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"lhbfx 收盘后更新完成 - {trade_date}",
|
||||
"",
|
||||
f"龙虎榜概览更新数: {overview_count}",
|
||||
f"席位明细更新数: {detail_count}",
|
||||
f"游资重新匹配数: {rematch_updated}",
|
||||
f"预警总数: {warning_total}",
|
||||
"",
|
||||
]
|
||||
lines.append(report_body)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
config = load_config()
|
||||
trade_date = args.trade_date or default_trade_date()
|
||||
|
||||
if not args.force and not is_weekday(trade_date):
|
||||
print(f"Skip {trade_date}: not a weekday, assumed non-trading day.")
|
||||
return
|
||||
|
||||
import_result = import_daily(trade_date, config=config)
|
||||
if import_result.overview_count <= 0:
|
||||
print(f"Skip {trade_date}: no overview rows imported, source data may not be ready or market may be closed.")
|
||||
return
|
||||
|
||||
rematch_result = rematch_traders(config=config)
|
||||
warning_result = generate_warnings(config=config)
|
||||
|
||||
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)
|
||||
|
||||
print(
|
||||
"After-market update finished:",
|
||||
{
|
||||
"trade_date": trade_date,
|
||||
"overview_count": import_result.overview_count,
|
||||
"detail_count": import_result.detail_count,
|
||||
"rematch_updated": rematch_result["updated"],
|
||||
"warning_total": warning_result["total"],
|
||||
"pdf_path": str(pdf_path),
|
||||
},
|
||||
)
|
||||
|
||||
if args.send_email:
|
||||
if config.mail is None:
|
||||
raise RuntimeError("Mail config is missing")
|
||||
|
||||
report_body = build_email_body(report)
|
||||
body_text = build_update_email_body(
|
||||
trade_date=trade_date,
|
||||
overview_count=import_result.overview_count,
|
||||
detail_count=import_result.detail_count,
|
||||
rematch_updated=rematch_result["updated"],
|
||||
warning_total=warning_result["total"],
|
||||
report_body=report_body,
|
||||
)
|
||||
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()
|
||||
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
|
||||
@ -8,6 +8,7 @@ from typing import Any
|
||||
from .db import db_cursor
|
||||
from .sources.eastmoney import EastMoneyClient
|
||||
from .sources.sina import SinaClient
|
||||
from .sources.tencent import TencentClient
|
||||
|
||||
|
||||
def _normalize_value(value: Any) -> Any:
|
||||
@ -36,22 +37,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 +122,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 +289,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 +329,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"])
|
||||
|
||||
@ -354,8 +371,7 @@ def fetch_trader_detail(trader_id: int) -> dict[str, Any]:
|
||||
)
|
||||
seats = [_normalize_row(row) for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
stock_query = """
|
||||
SELECT
|
||||
d.stock_code,
|
||||
MAX(COALESCE(o.stock_name, d.stock_name)) AS stock_name,
|
||||
@ -365,22 +381,61 @@ def fetch_trader_detail(trader_id: int) -> dict[str, Any]:
|
||||
MAX(d.trade_date) AS last_trade_date,
|
||||
SUM(CASE WHEN CAST(COALESCE(NULLIF(d.buy_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS buy_action_count,
|
||||
SUM(CASE WHEN CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS sell_action_count,
|
||||
SUM(CAST(COALESCE(NULLIF(d.net_amount_wan, ''), '0') AS DECIMAL(18,2))) AS total_net_amount_wan,
|
||||
MAX(CASE WHEN w.warning_type = 'sell_alert' THEN 1 ELSE 0 END) AS has_sell_alert,
|
||||
MAX(CASE WHEN w.warning_type = 'slow_exit_watch' THEN 1 ELSE 0 END) AS has_slow_exit
|
||||
MAX(CASE WHEN w.warning_type = 'slow_exit_watch' THEN 1 ELSE 0 END) AS has_slow_exit,
|
||||
MAX(s.industry) AS industry,
|
||||
MAX(s.market) AS market,
|
||||
MAX(s.total_market_value) AS total_market_value,
|
||||
MAX(s.circulating_market_value) AS circulating_market_value
|
||||
FROM lhb_detail_seats d
|
||||
LEFT JOIN lhb_overview o
|
||||
ON o.stock_code = d.stock_code AND o.trade_date = d.trade_date
|
||||
LEFT JOIN warning_events w
|
||||
ON w.stock_code = d.stock_code AND w.trader_name = d.matched_trader_name
|
||||
LEFT JOIN stocks s
|
||||
ON s.stock_code = d.stock_code
|
||||
WHERE d.matched_trader_name = %s
|
||||
GROUP BY d.stock_code
|
||||
ORDER BY last_trade_date DESC, action_count DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
(trader_name,),
|
||||
)
|
||||
"""
|
||||
|
||||
cursor.execute(stock_query, (trader_name,))
|
||||
stocks = [_normalize_row(row) for row in cursor.fetchall()]
|
||||
|
||||
stock_codes = [row["stock_code"] for row in stocks if row.get("stock_code")]
|
||||
increasing_by_stock: dict[str, bool] = {}
|
||||
if stock_codes:
|
||||
placeholders = ", ".join(["%s"] * len(stock_codes))
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
stock_code,
|
||||
trade_date,
|
||||
SUM(CAST(COALESCE(NULLIF(net_amount_wan, ''), '0') AS DECIMAL(18,2))) AS daily_net_amount_wan
|
||||
FROM lhb_detail_seats
|
||||
WHERE matched_trader_name = %s
|
||||
AND stock_code IN ({placeholders})
|
||||
AND trade_date IS NOT NULL
|
||||
GROUP BY stock_code, trade_date
|
||||
ORDER BY stock_code, trade_date
|
||||
""",
|
||||
(trader_name, *stock_codes),
|
||||
)
|
||||
net_history_by_stock: dict[str, list[float]] = {}
|
||||
for row in cursor.fetchall():
|
||||
stock_code = row["stock_code"]
|
||||
net_history_by_stock.setdefault(stock_code, []).append(float(row["daily_net_amount_wan"] or 0))
|
||||
|
||||
for stock_code, net_history in net_history_by_stock.items():
|
||||
increasing_by_stock[stock_code] = len(net_history) >= 2 and all(
|
||||
current > previous for previous, current in zip(net_history, net_history[1:])
|
||||
)
|
||||
|
||||
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
|
||||
@ -413,6 +468,11 @@ def fetch_stock_detail(stock_code: str) -> dict[str, Any]:
|
||||
quote_snapshot = eastmoney.fetch_quote_snapshot(stock_code)
|
||||
except Exception:
|
||||
quote_snapshot = {}
|
||||
if not quote_snapshot:
|
||||
try:
|
||||
quote_snapshot = TencentClient().fetch_quote_snapshot(stock_code)
|
||||
except Exception:
|
||||
quote_snapshot = {}
|
||||
if not market_daily:
|
||||
try:
|
||||
market_daily = SinaClient().fetch_daily_kline(stock_code)
|
||||
|
||||
276
backend/src/lhbfx/reporting.py
Normal file
276
backend/src/lhbfx/reporting.py
Normal file
@ -0,0 +1,276 @@
|
||||
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
|
||||
from .sources.tencent import TencentClient
|
||||
|
||||
|
||||
@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()
|
||||
quote_client = TencentClient()
|
||||
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"))
|
||||
has_market_value = bool(row and row.get("total_market_value") is not None)
|
||||
has_circulating_market_value = bool(row and row.get("circulating_market_value") is not None)
|
||||
if has_industry and has_concepts and has_market_value and has_circulating_market_value:
|
||||
continue
|
||||
|
||||
profile = client.fetch_company_profile(stock_code)
|
||||
try:
|
||||
quote_snapshot = quote_client.fetch_quote_snapshot(stock_code)
|
||||
except Exception:
|
||||
quote_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 quote_snapshot.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),
|
||||
quote_snapshot.get("total_market_value"),
|
||||
quote_snapshot.get("circulating_market_value"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
81
backend/src/lhbfx/sources/tencent.py
Normal file
81
backend/src/lhbfx/sources/tencent.py
Normal file
@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/135.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://finance.qq.com/",
|
||||
}
|
||||
|
||||
|
||||
def infer_symbol(stock_code: str) -> str:
|
||||
if stock_code.startswith(("6", "9", "5", "688")):
|
||||
return f"sh{stock_code}"
|
||||
return f"sz{stock_code}"
|
||||
|
||||
|
||||
def _to_float(value: str | None) -> float | None:
|
||||
if value in (None, "", "-"):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class TencentClient:
|
||||
def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]:
|
||||
symbol = infer_symbol(stock_code)
|
||||
response = requests.get(
|
||||
"https://qt.gtimg.cn/q=" + symbol,
|
||||
headers=DEFAULT_HEADERS,
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
if '"' not in text:
|
||||
return {}
|
||||
|
||||
payload = text.split('"', 1)[1].rsplit('"', 1)[0]
|
||||
parts = payload.split("~")
|
||||
if len(parts) < 49:
|
||||
return {}
|
||||
|
||||
latest_price = _to_float(parts[3])
|
||||
previous_close = _to_float(parts[4])
|
||||
open_price = _to_float(parts[5])
|
||||
price_chg = _to_float(parts[31])
|
||||
pct_chg = _to_float(parts[32])
|
||||
high_price = _to_float(parts[33])
|
||||
low_price = _to_float(parts[34])
|
||||
amount_wan = _to_float(parts[37])
|
||||
turnover_pct = _to_float(parts[38])
|
||||
amplitude_pct = _to_float(parts[43])
|
||||
circulating_market_value_yi = _to_float(parts[44])
|
||||
total_market_value_yi = _to_float(parts[45])
|
||||
|
||||
return {
|
||||
"stock_code": parts[2] or stock_code,
|
||||
"stock_name": parts[1] or None,
|
||||
"latest_price": None if latest_price is None else latest_price * 100,
|
||||
"high_price": None if high_price is None else high_price * 100,
|
||||
"low_price": None if low_price is None else low_price * 100,
|
||||
"open_price": None if open_price is None else open_price * 100,
|
||||
"amount": None if amount_wan is None else amount_wan * 10000,
|
||||
"previous_close": None if previous_close is None else previous_close * 100,
|
||||
"turnover": None if turnover_pct is None else turnover_pct * 100,
|
||||
"price_chg": None if price_chg is None else price_chg * 100,
|
||||
"pct_chg": None if pct_chg is None else pct_chg * 100,
|
||||
"amplitude": None if amplitude_pct is None else amplitude_pct * 100,
|
||||
"circulating_market_value": (
|
||||
None if circulating_market_value_yi is None else circulating_market_value_yi * 100000000
|
||||
),
|
||||
"total_market_value": None if total_market_value_yi is None else total_market_value_yi * 100000000,
|
||||
}
|
||||
95
docs/技术文档.md
95
docs/技术文档.md
@ -140,7 +140,7 @@ longhubang/
|
||||
### 4.3 页面组件
|
||||
|
||||
- `HomeControlScreen.vue`:首页总控台
|
||||
- `TraderDetailScreen.vue`:游资详情
|
||||
- `TraderDetailScreen.vue`:游资详情,采用单列全宽股票列表,支持时间、名称、净额连续增大筛选,并展示行业、板块、总市值、净额和预警标签
|
||||
- `StockDetailScreen.vue`:个股详情
|
||||
- `WarningCenterScreen.vue`:预警中心
|
||||
|
||||
@ -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 是否成功生成
|
||||
- 邮件正文是否包含关键摘要
|
||||
- 附件是否能正常打开
|
||||
- 多收件人场景是否发送成功
|
||||
|
||||
49
docs/需求文档.md
49
docs/需求文档.md
@ -64,9 +64,11 @@
|
||||
游资详情页支持:
|
||||
|
||||
- 游资档案
|
||||
- 核心席位
|
||||
- 近期参与股票
|
||||
- 风格标签
|
||||
- 股票列表全宽展示
|
||||
- 时间、名称、净额连续增大筛选
|
||||
- 按时间、净额、动作数排序
|
||||
- 行业、上市板块、总市值、净额和预警标签展示
|
||||
|
||||
### 2.6 预警中心
|
||||
|
||||
@ -152,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. 后续建议
|
||||
|
||||
后续可以继续迭代:
|
||||
|
||||
|
||||
@ -144,9 +144,11 @@
|
||||
|
||||
游资详情页保留,但不再作为首页首要信息。
|
||||
|
||||
游资详情页用于查看某个游资的长期参与情况、席位信息、风格标签和近期参与股票。
|
||||
游资详情页用于查看某个游资的长期参与情况、风格标签和近期参与股票。
|
||||
|
||||
已移除“动作时间线”模块。
|
||||
已移除“净额重点”和“席位概览”侧栏,股票列表改为单列全宽展示。
|
||||
股票列表支持按时间、名称、净额连续增大筛选,并支持按时间、净额、动作数排序。
|
||||
|
||||
原因:
|
||||
|
||||
@ -206,3 +208,4 @@
|
||||
6. 首页顶部指标与关注列表联动,取消关注后自动剔除统计。
|
||||
7. 所有股票入口都能跳转到股票详情。
|
||||
8. 游资详情页去掉动作时间线。
|
||||
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>
|
||||
|
||||
522
frontend/src/components/StockActionTimelineChart.vue
Normal file
522
frontend/src/components/StockActionTimelineChart.vue
Normal file
@ -0,0 +1,522 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, shallowRef, useTemplateRef } from 'vue'
|
||||
|
||||
import type { TraderAction } from '../types'
|
||||
import { formatSignedWanAmount, formatWanAmount, numberFromText } from '../utils/format'
|
||||
|
||||
type AggregatedActionRow = {
|
||||
trade_date: string
|
||||
buyTotalWan: number
|
||||
sellTotalWan: number
|
||||
netTotalWan: number
|
||||
cumulativeNetWan: number
|
||||
traderCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
actions: TraderAction[]
|
||||
}>()
|
||||
|
||||
const chartContainerRef = useTemplateRef<HTMLDivElement>('chartContainerRef')
|
||||
const hoveredIndex = shallowRef<number | null>(null)
|
||||
const containerWidth = shallowRef(0)
|
||||
|
||||
const aggregatedRows = computed<AggregatedActionRow[]>(() => {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
trade_date: string
|
||||
buyTotalWan: number
|
||||
sellTotalWan: number
|
||||
netTotalWan: number
|
||||
traders: Set<string>
|
||||
}
|
||||
>()
|
||||
|
||||
for (const action of props.actions) {
|
||||
const buyTotalWan = numberFromText(action.buy_amount_wan) ?? 0
|
||||
const sellTotalWan = numberFromText(action.sell_amount_wan) ?? 0
|
||||
const netTotalWan = numberFromText(action.net_amount_wan) ?? buyTotalWan - sellTotalWan
|
||||
const current = grouped.get(action.trade_date) ?? {
|
||||
trade_date: action.trade_date,
|
||||
buyTotalWan: 0,
|
||||
sellTotalWan: 0,
|
||||
netTotalWan: 0,
|
||||
traders: new Set<string>(),
|
||||
}
|
||||
|
||||
current.buyTotalWan += buyTotalWan
|
||||
current.sellTotalWan += sellTotalWan
|
||||
current.netTotalWan += netTotalWan
|
||||
if (action.matched_trader_name) {
|
||||
current.traders.add(action.matched_trader_name)
|
||||
}
|
||||
grouped.set(action.trade_date, current)
|
||||
}
|
||||
|
||||
let cumulativeNetWan = 0
|
||||
return [...grouped.values()]
|
||||
.sort((left, right) => left.trade_date.localeCompare(right.trade_date))
|
||||
.map((item) => {
|
||||
cumulativeNetWan += item.netTotalWan
|
||||
return {
|
||||
trade_date: item.trade_date,
|
||||
buyTotalWan: item.buyTotalWan,
|
||||
sellTotalWan: item.sellTotalWan,
|
||||
netTotalWan: item.netTotalWan,
|
||||
cumulativeNetWan,
|
||||
traderCount: item.traders.size,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const chartModel = computed(() => {
|
||||
const rows = aggregatedRows.value
|
||||
const measuredWidth = containerWidth.value || 0
|
||||
const width = Math.max(measuredWidth, 320, rows.length * 54)
|
||||
const height = 260
|
||||
const left = 56
|
||||
const right = 64
|
||||
const top = 18
|
||||
const bottom = 34
|
||||
const innerWidth = width - left - right
|
||||
const innerHeight = height - top - bottom
|
||||
|
||||
const emptyModel = {
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
zeroY: top + innerHeight / 2,
|
||||
stepX: 0,
|
||||
buyBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
|
||||
sellBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
|
||||
netPoints: [] as Array<{ x: number; y: number }>,
|
||||
cumulativePoints: [] as Array<{ x: number; y: number }>,
|
||||
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
|
||||
leftAxis: [] as Array<{ y: number; label: string }>,
|
||||
rightAxis: [] as Array<{ y: number; label: string }>,
|
||||
hoverColumns: [] as Array<{ x: number; width: number }>,
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return emptyModel
|
||||
}
|
||||
|
||||
const leftMax = Math.max(
|
||||
...rows.flatMap((row) => [Math.abs(row.buyTotalWan), Math.abs(row.sellTotalWan), Math.abs(row.netTotalWan)]),
|
||||
1,
|
||||
)
|
||||
const zeroY = top + innerHeight / 2
|
||||
const yOfLeft = (value: number) => zeroY - (value / leftMax) * (innerHeight / 2)
|
||||
|
||||
const cumulativeValues = rows.map((row) => row.cumulativeNetWan)
|
||||
const rightMin = Math.min(0, ...cumulativeValues)
|
||||
const rightMax = Math.max(0, ...cumulativeValues)
|
||||
const rightRange = rightMax - rightMin || 1
|
||||
const yOfRight = (value: number) => top + ((rightMax - value) / rightRange) * innerHeight
|
||||
|
||||
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / (rows.length - 1)
|
||||
const groupWidth = Math.max(20, Math.min(28, stepX * 0.58))
|
||||
const barWidth = Math.max(7, groupWidth / 2 - 2)
|
||||
const labelStep = Math.max(1, Math.ceil(rows.length / 6))
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
zeroY,
|
||||
stepX,
|
||||
buyBars: rows.map((row, index) => {
|
||||
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
|
||||
const y = yOfLeft(row.buyTotalWan)
|
||||
return {
|
||||
x: x - barWidth - 2,
|
||||
y,
|
||||
height: Math.max(2, zeroY - y),
|
||||
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
|
||||
}
|
||||
}),
|
||||
sellBars: rows.map((row, index) => {
|
||||
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
|
||||
const y = zeroY
|
||||
const sellY = yOfLeft(-row.sellTotalWan)
|
||||
return {
|
||||
x: x + 2,
|
||||
y,
|
||||
height: Math.max(2, sellY - zeroY),
|
||||
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
|
||||
}
|
||||
}),
|
||||
netPoints: rows.map((row, index) => ({
|
||||
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||
y: yOfLeft(row.netTotalWan),
|
||||
})),
|
||||
cumulativePoints: rows.map((row, index) => ({
|
||||
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||
y: yOfRight(row.cumulativeNetWan),
|
||||
})),
|
||||
labels: rows.map((row, index) => ({
|
||||
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||
label: row.trade_date.slice(5),
|
||||
visible: index % labelStep === 0 || index === rows.length - 1,
|
||||
})),
|
||||
leftAxis: Array.from({ length: 5 }, (_, index) => {
|
||||
const value = leftMax - (leftMax * 2 * index) / 4
|
||||
return { y: yOfLeft(value), label: formatSignedWanAmount(value) }
|
||||
}),
|
||||
rightAxis: Array.from({ length: 5 }, (_, index) => {
|
||||
const value = rightMax - (rightRange * index) / 4
|
||||
return { y: yOfRight(value), label: formatSignedWanAmount(value) }
|
||||
}),
|
||||
hoverColumns: rows.map((_row, index) => ({
|
||||
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX) - Math.max(stepX, 20) / 2,
|
||||
width: Math.max(stepX, 20),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const netLinePoints = computed(() => chartModel.value.netPoints.map((point) => `${point.x},${point.y}`).join(' '))
|
||||
const cumulativeLinePoints = computed(() =>
|
||||
chartModel.value.cumulativePoints.map((point) => `${point.x},${point.y}`).join(' '),
|
||||
)
|
||||
|
||||
const activeRow = computed(() => {
|
||||
if (!aggregatedRows.value.length) return null
|
||||
if (hoveredIndex.value === null) return null
|
||||
return aggregatedRows.value[hoveredIndex.value] ?? null
|
||||
})
|
||||
|
||||
const activeTooltip = computed(() => {
|
||||
if (hoveredIndex.value === null || !activeRow.value) return null
|
||||
const point = chartModel.value.netPoints[hoveredIndex.value]
|
||||
if (!point) return null
|
||||
|
||||
const tooltipWidth = 168
|
||||
const tooltipHeight = 90
|
||||
const x = Math.min(
|
||||
Math.max(point.x - tooltipWidth / 2, chartModel.value.left + 6),
|
||||
chartModel.value.width - chartModel.value.right - tooltipWidth - 6,
|
||||
)
|
||||
const prefersBelow = point.y < chartModel.value.top + tooltipHeight + 24
|
||||
const y = prefersBelow
|
||||
? Math.min(point.y + 12, chartModel.value.height - chartModel.value.bottom - tooltipHeight - 6)
|
||||
: Math.max(point.y - tooltipHeight - 12, chartModel.value.top + 6)
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: tooltipWidth,
|
||||
height: tooltipHeight,
|
||||
}
|
||||
})
|
||||
|
||||
function setHoveredIndex(index: number) {
|
||||
hoveredIndex.value = index
|
||||
}
|
||||
|
||||
function clearHoveredIndex() {
|
||||
hoveredIndex.value = null
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function syncContainerWidth() {
|
||||
containerWidth.value = chartContainerRef.value?.clientWidth ?? 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncContainerWidth()
|
||||
if (chartContainerRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncContainerWidth()
|
||||
})
|
||||
resizeObserver.observe(chartContainerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-chart-panel">
|
||||
<div class="action-chart-head">
|
||||
<div>
|
||||
<h4 class="action-chart-title">买卖力度趋势</h4>
|
||||
<p class="action-chart-note">柱形图显示买卖,折线显示单日净额和累计净额。</p>
|
||||
</div>
|
||||
<div class="action-chart-legend">
|
||||
<span class="legend-chip">
|
||||
<span class="legend-swatch bar buy" />
|
||||
买入
|
||||
</span>
|
||||
<span class="legend-chip">
|
||||
<span class="legend-swatch bar sell" />
|
||||
卖出
|
||||
</span>
|
||||
<span class="legend-chip">
|
||||
<span class="legend-swatch line net" />
|
||||
当日净额
|
||||
</span>
|
||||
<span class="legend-chip">
|
||||
<span class="legend-swatch line cumulative" />
|
||||
累计净额
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="aggregatedRows.length" class="action-chart-body">
|
||||
<div ref="chartContainerRef" class="action-chart-scroll">
|
||||
<svg
|
||||
class="action-chart-svg"
|
||||
:viewBox="`0 0 ${chartModel.width} ${chartModel.height}`"
|
||||
:style="{ width: `${chartModel.width}px`, height: `${chartModel.height}px` }"
|
||||
preserveAspectRatio="none"
|
||||
@mouseleave="clearHoveredIndex"
|
||||
>
|
||||
<g opacity="0.08" stroke="#ffffff">
|
||||
<line
|
||||
v-for="tick in chartModel.leftAxis"
|
||||
:key="`left-grid-${tick.label}`"
|
||||
:x1="chartModel.left"
|
||||
:y1="tick.y"
|
||||
:x2="chartModel.width - chartModel.right"
|
||||
:y2="tick.y"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<line
|
||||
:x1="chartModel.left"
|
||||
:y1="chartModel.zeroY"
|
||||
:x2="chartModel.width - chartModel.right"
|
||||
:y2="chartModel.zeroY"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
|
||||
<g v-for="(tick, index) in chartModel.leftAxis" :key="`left-axis-${index}`">
|
||||
<text x="0" :y="tick.y + 4" fill="#93a2b5" font-size="10">{{ tick.label }}</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(tick, index) in chartModel.rightAxis" :key="`right-axis-${index}`">
|
||||
<text :x="chartModel.width - chartModel.right + 8" :y="tick.y + 4" fill="#c9ad68" font-size="10">
|
||||
{{ tick.label }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(column, index) in chartModel.hoverColumns" :key="`hover-${index}`">
|
||||
<rect
|
||||
:x="column.x"
|
||||
y="0"
|
||||
:width="column.width"
|
||||
:height="chartModel.height"
|
||||
:fill="hoveredIndex === index ? 'rgba(255,255,255,0.04)' : 'transparent'"
|
||||
@mouseenter="setHoveredIndex(index)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g v-for="(bar, index) in chartModel.buyBars" :key="`buy-${index}`">
|
||||
<rect
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
width="10"
|
||||
:height="bar.height"
|
||||
rx="2"
|
||||
fill="#ff7b7b"
|
||||
>
|
||||
<title>{{ bar.title }}</title>
|
||||
</rect>
|
||||
</g>
|
||||
|
||||
<g v-for="(bar, index) in chartModel.sellBars" :key="`sell-${index}`">
|
||||
<rect
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
width="10"
|
||||
:height="bar.height"
|
||||
rx="2"
|
||||
fill="#4ca8ff"
|
||||
>
|
||||
<title>{{ bar.title }}</title>
|
||||
</rect>
|
||||
</g>
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#f0c96a"
|
||||
stroke-width="2.4"
|
||||
:points="cumulativeLinePoints"
|
||||
/>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#7de1b2"
|
||||
stroke-width="2.2"
|
||||
:points="netLinePoints"
|
||||
/>
|
||||
|
||||
<g v-for="(point, index) in chartModel.netPoints" :key="`net-point-${index}`">
|
||||
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#7de1b2" />
|
||||
</g>
|
||||
<g v-for="(point, index) in chartModel.cumulativePoints" :key="`cumulative-point-${index}`">
|
||||
<circle :cx="point.x" :cy="point.y" r="3.2" fill="#f0c96a" />
|
||||
</g>
|
||||
|
||||
<g v-if="activeTooltip && activeRow">
|
||||
<rect
|
||||
:x="activeTooltip.x"
|
||||
:y="activeTooltip.y"
|
||||
:width="activeTooltip.width"
|
||||
:height="activeTooltip.height"
|
||||
rx="10"
|
||||
fill="rgba(8, 12, 18, 0.96)"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
/>
|
||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 18" fill="#ffffff" font-size="11" font-weight="700">
|
||||
{{ activeRow.trade_date }}
|
||||
</text>
|
||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#ff7b7b" font-size="11">
|
||||
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
|
||||
</text>
|
||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#4ca8ff" font-size="11">
|
||||
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
|
||||
</text>
|
||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#7de1b2" font-size="11">
|
||||
当日净额 {{ formatSignedWanAmount(activeRow.netTotalWan) }}
|
||||
</text>
|
||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#f0c96a" font-size="11">
|
||||
累计净额 {{ formatSignedWanAmount(activeRow.cumulativeNetWan) }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(label, index) in chartModel.labels" :key="`label-${index}`">
|
||||
<text
|
||||
v-if="label.visible"
|
||||
:x="label.x"
|
||||
:y="chartModel.height - 10"
|
||||
text-anchor="middle"
|
||||
fill="#93a2b5"
|
||||
font-size="10"
|
||||
>
|
||||
{{ label.label }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="action-chart-empty">暂无可展示的买卖明细。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-chart-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.action-chart-head {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-chart-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.action-chart-note {
|
||||
margin: 6px 0 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.action-chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.legend-swatch.bar {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-swatch.buy {
|
||||
background: #ff7b7b;
|
||||
}
|
||||
|
||||
.legend-swatch.sell {
|
||||
background: #4ca8ff;
|
||||
}
|
||||
|
||||
.legend-swatch.line::before {
|
||||
content: '';
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-swatch.line.net::before {
|
||||
background: #7de1b2;
|
||||
}
|
||||
|
||||
.legend-swatch.line.cumulative::before {
|
||||
background: #f0c96a;
|
||||
}
|
||||
|
||||
.action-chart-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.action-chart-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 2px;
|
||||
min-height: 228px;
|
||||
}
|
||||
|
||||
.action-chart-svg {
|
||||
display: block;
|
||||
min-height: 228px;
|
||||
}
|
||||
|
||||
.action-chart-empty {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef, useTemplateRef } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, shallowRef, useTemplateRef } from 'vue'
|
||||
|
||||
import StockActionTimelineChart from './StockActionTimelineChart.vue'
|
||||
import type { StockDetail, WarningItem } from '../types'
|
||||
import {
|
||||
compactMoney,
|
||||
@ -44,8 +45,10 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const chartRef = useTemplateRef<SVGSVGElement>('chartRef')
|
||||
const warningPopoverRef = useTemplateRef<HTMLDivElement>('warningPopoverRef')
|
||||
const selectedZoom = shallowRef(80)
|
||||
const hoveredIndex = shallowRef<number | null>(null)
|
||||
const showWarningPanel = shallowRef(false)
|
||||
|
||||
const zoomOptions = [
|
||||
{ label: '近40日', count: 40 },
|
||||
@ -60,11 +63,6 @@ const chartLegendItems = [
|
||||
{ label: 'S 卖出', tone: '#5ab8ff', type: 'dot' },
|
||||
] as const
|
||||
|
||||
function validText(value: string | null | undefined): string | null {
|
||||
if (!value || value === '-') return null
|
||||
return value
|
||||
}
|
||||
|
||||
function formatPercentNumber(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return `${value.toFixed(2)}%`
|
||||
@ -82,10 +80,9 @@ function valueOrFallback(primary: string | null | undefined, fallback: string |
|
||||
}
|
||||
|
||||
const topWarning = computed(() => props.activeWarning ?? props.stockDetail?.warnings[0] ?? null)
|
||||
const allWarnings = computed(() => props.stockDetail?.warnings ?? [])
|
||||
const latestOverview = computed(() => props.stockDetail?.overview?.[0] ?? null)
|
||||
const displayedTraderSummary = computed(() => (props.stockDetail?.trader_summary ?? []).slice(0, 4))
|
||||
const displayedTraderActions = computed(() => (props.stockDetail?.trader_actions ?? []).slice(0, 6))
|
||||
const displayedWarnings = computed(() => (props.stockDetail?.warnings ?? []).slice(0, 3))
|
||||
|
||||
const totalNetAmountWan = computed(() => {
|
||||
return (props.stockDetail?.trader_summary ?? []).reduce((sum, item) => sum + (item.total_net_amount_wan ?? 0), 0)
|
||||
@ -97,17 +94,26 @@ const baseFacts = computed(() => {
|
||||
const latest = latestOverview.value
|
||||
if (!stock) return []
|
||||
|
||||
const latestPrice = snapshot?.latest_price ?? numberFromText(latest?.price) ?? null
|
||||
const latestPct = snapshot?.pct_chg ?? numberFromText(validText(latest?.pct_chg)) ?? null
|
||||
const latestAmount = snapshot?.amount ?? numberFromText(latest?.amount) ?? null
|
||||
const latestPriceValue =
|
||||
snapshot?.latest_price != null
|
||||
? (snapshot.latest_price / 100).toFixed(2)
|
||||
: valueOrFallback(latest?.price, '-')
|
||||
const latestPctValue =
|
||||
snapshot?.pct_chg != null
|
||||
? formatPercentNumber(snapshot.pct_chg / 100)
|
||||
: valueOrFallback(latest?.pct_chg, '-')
|
||||
const latestAmountValue =
|
||||
snapshot?.amount != null
|
||||
? formatAmountNumber(snapshot.amount)
|
||||
: valueOrFallback(latest?.amount, '-')
|
||||
|
||||
return [
|
||||
{ label: '代码', value: stock.stock_code },
|
||||
{ label: '市场', value: stock.market || 'A股' },
|
||||
{ label: '行业', value: stock.industry || snapshot?.industry || '-' },
|
||||
{ label: '最新价', value: latestPrice === null ? '-' : String((latestPrice / 100).toFixed(2)) },
|
||||
{ label: '涨跌幅', value: latestPct === null ? valueOrFallback(latest?.pct_chg, '-') : formatPercentNumber(latestPct / 100) },
|
||||
{ label: '成交额', value: latestAmount === null ? valueOrFallback(latest?.amount, '-') : formatAmountNumber(latestAmount) },
|
||||
{ label: '最新价', value: latestPriceValue },
|
||||
{ label: '涨跌幅', value: latestPctValue },
|
||||
{ label: '成交额', value: latestAmountValue },
|
||||
{ label: '振幅', value: snapshot?.amplitude == null ? '-' : formatPercentNumber(snapshot.amplitude / 100) },
|
||||
{ label: '换手', value: snapshot?.turnover == null ? '-' : formatPercentNumber(snapshot.turnover / 100) },
|
||||
{ label: '总市值', value: compactMoney(stock.total_market_value ?? snapshot?.total_market_value ?? null) },
|
||||
@ -294,6 +300,31 @@ function handleChartMove(event: MouseEvent) {
|
||||
function clearHover() {
|
||||
hoveredIndex.value = null
|
||||
}
|
||||
|
||||
function toggleWarningPanel() {
|
||||
showWarningPanel.value = !showWarningPanel.value
|
||||
}
|
||||
|
||||
function closeWarningPanel() {
|
||||
showWarningPanel.value = false
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
const root = warningPopoverRef.value
|
||||
const target = event.target
|
||||
if (!root || !(target instanceof Node)) return
|
||||
if (!root.contains(target)) {
|
||||
closeWarningPanel()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -308,6 +339,42 @@ function clearHover() {
|
||||
</div>
|
||||
<div class="screen-toolbar">
|
||||
<span class="pill active">日K + 操作观点</span>
|
||||
<div ref="warningPopoverRef" class="warning-popover-wrap">
|
||||
<button
|
||||
class="pill warning-trigger"
|
||||
:class="{ alert: allWarnings.length > 0, open: showWarningPanel }"
|
||||
type="button"
|
||||
@click.stop="toggleWarningPanel"
|
||||
>
|
||||
预警列表
|
||||
<span v-if="allWarnings.length">· {{ allWarnings.length }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showWarningPanel" class="warning-popover">
|
||||
<div class="warning-popover-head">
|
||||
<strong>当前预警情况</strong>
|
||||
<span>{{ allWarnings.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="allWarnings.length" class="warning-popover-list">
|
||||
<div
|
||||
v-for="warning in allWarnings"
|
||||
:key="`${warning.trade_date}-${warning.warning_type}-${warning.trader_name}`"
|
||||
class="warning-popover-item"
|
||||
>
|
||||
<div class="warning-popover-top">
|
||||
<strong>{{ warning.trader_name }}</strong>
|
||||
<span class="tag" :class="warning.warning_level === 'high' ? 'red' : 'orange'">
|
||||
{{ warningLabel(warning.warning_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ warning.trade_date }} · {{ warning.trigger_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="warning-popover-empty">当前没有预警,状态正常。</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill" v-if="topWarning">{{ warningLabel(topWarning.warning_type) }}</span>
|
||||
<span class="pill" v-if="stockDetail">{{ stockDetail.stock.stock_code }}</span>
|
||||
</div>
|
||||
@ -515,46 +582,8 @@ function clearHover() {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<div class="section-head">
|
||||
<h4 class="side-title">买卖明细</h4>
|
||||
<span class="pill">{{ displayedTraderActions.length }} 条</span>
|
||||
</div>
|
||||
<div class="detail-list">
|
||||
<div
|
||||
v-for="action in displayedTraderActions"
|
||||
:key="`${action.trade_date}-${action.matched_trader_name}-${action.seat_name}-${action.table_title}`"
|
||||
class="detail-item"
|
||||
>
|
||||
<div class="flow-top">
|
||||
<strong>{{ action.matched_trader_name }}</strong>
|
||||
<span>{{ action.trade_date }}</span>
|
||||
</div>
|
||||
<div class="flow-line buy">买入 {{ formatWanAmount(action.buy_amount_wan) }}</div>
|
||||
<div class="flow-line sell">卖出 {{ formatWanAmount(action.sell_amount_wan) }}</div>
|
||||
<div class="flow-line net" :class="priceTone(String(action.net_amount_wan))">
|
||||
净额 {{ formatSignedWanAmount(action.net_amount_wan) }}
|
||||
</div>
|
||||
<p class="detail-desc">{{ action.seat_name }} · {{ action.table_title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<h4 class="side-title">预警情况</h4>
|
||||
<div
|
||||
v-for="warning in displayedWarnings"
|
||||
:key="`${warning.trade_date}-${warning.warning_type}-${warning.trader_name}`"
|
||||
class="timeline-entry"
|
||||
>
|
||||
<div class="timeline-top">
|
||||
<strong>{{ warning.trader_name }}</strong>
|
||||
<span class="tag" :class="warning.warning_level === 'high' ? 'red' : 'orange'">
|
||||
{{ warningLabel(warning.warning_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="timeline-desc">{{ warning.trade_date }} · {{ warning.trigger_reason }}</p>
|
||||
</div>
|
||||
<article class="side-card action-chart-card">
|
||||
<StockActionTimelineChart :actions="stockDetail.trader_actions" />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@ -564,7 +593,7 @@ function clearHover() {
|
||||
<style scoped>
|
||||
.screen {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 30px;
|
||||
@ -610,6 +639,7 @@ function clearHover() {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pill,
|
||||
@ -638,6 +668,83 @@ function clearHover() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warning-popover-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.warning-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warning-trigger.alert {
|
||||
color: #ffb4b4;
|
||||
background: rgba(255, 93, 93, 0.12);
|
||||
border-color: rgba(255, 93, 93, 0.2);
|
||||
}
|
||||
|
||||
.warning-trigger.alert.open {
|
||||
background: rgba(255, 93, 93, 0.18);
|
||||
}
|
||||
|
||||
.warning-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: min(380px, calc(100vw - 48px));
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 12, 18, 0.98);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.warning-popover {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: translateX(-22%);
|
||||
}
|
||||
}
|
||||
|
||||
.warning-popover-head,
|
||||
.warning-popover-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.warning-popover-head {
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warning-popover-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.warning-popover-item {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.warning-popover-item p,
|
||||
.warning-popover-empty {
|
||||
margin: 8px 0 0;
|
||||
color: #d6ceb9;
|
||||
line-height: 1.55;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@ -688,6 +795,8 @@ function clearHover() {
|
||||
gap: 14px;
|
||||
height: calc(100% - 52px);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.main-stack,
|
||||
@ -702,7 +811,7 @@ function clearHover() {
|
||||
}
|
||||
|
||||
.side-stack {
|
||||
grid-template-rows: auto repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: auto auto minmax(280px, 1fr);
|
||||
}
|
||||
|
||||
.card-panel,
|
||||
@ -840,9 +949,7 @@ function clearHover() {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.judge-desc,
|
||||
.timeline-desc,
|
||||
.detail-desc {
|
||||
.judge-desc {
|
||||
margin: 0;
|
||||
color: #d6ceb9;
|
||||
line-height: 1.55;
|
||||
@ -862,16 +969,12 @@ function clearHover() {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.trader-flow,
|
||||
.detail-item,
|
||||
.timeline-entry {
|
||||
.trader-flow {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.trader-flow:last-child,
|
||||
.detail-item:last-child,
|
||||
.timeline-entry:last-child {
|
||||
.trader-flow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@ -906,6 +1009,10 @@ function clearHover() {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.action-chart-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import type { TraderDetail, TraderListItem } from '../types'
|
||||
import { formatDate, priceTone } from '../utils/format'
|
||||
import type { TraderDetail, TraderListItem, TraderStock } from '../types'
|
||||
import { compactMoney, formatDate, formatSignedWanAmount, priceTone } from '../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
traders: TraderListItem[]
|
||||
@ -15,31 +15,78 @@ const emit = defineEmits<{
|
||||
selectStock: [stockCode: string]
|
||||
}>()
|
||||
|
||||
const profileText = computed(() => {
|
||||
const selectedDateFilter = shallowRef('')
|
||||
const stockNameFilter = shallowRef('')
|
||||
const netGrowthFilter = shallowRef<'all' | 'increasing'>('all')
|
||||
const sortKey = shallowRef<'last_trade_date' | 'total_net_amount_wan' | 'action_count'>('last_trade_date')
|
||||
|
||||
function inferBoardLabel(stockCode: string): string {
|
||||
if (stockCode.startsWith('688')) return '科创板'
|
||||
if (stockCode.startsWith('300') || stockCode.startsWith('301')) return '创业板'
|
||||
if (stockCode.startsWith('8') || stockCode.startsWith('4') || stockCode.startsWith('920')) return '北交所'
|
||||
if (stockCode.startsWith('60') || stockCode.startsWith('601') || stockCode.startsWith('603') || stockCode.startsWith('605')) return '沪主板'
|
||||
if (stockCode.startsWith('000') || stockCode.startsWith('001') || stockCode.startsWith('002') || stockCode.startsWith('003')) return '深主板'
|
||||
return 'A股'
|
||||
}
|
||||
|
||||
const compactProfileText = computed(() => {
|
||||
const detail = props.traderDetail
|
||||
if (!detail) return '等待选择游资。'
|
||||
const tags = detail.trader.style_tags?.join('、') || '暂无风格标签'
|
||||
return `${detail.trader.name} 当前风格聚焦 ${tags}。本页只保留游资档案和近期参与股票列表,动作时间线已移除。`
|
||||
if (!detail) return ''
|
||||
const tags = detail.trader.style_tags?.slice(0, 3).join(' / ') || '暂无标签'
|
||||
const seatCount = detail.seats.length
|
||||
const stockCount = detail.stocks.length
|
||||
const warningCount = detail.stocks.filter((item) => item.has_sell_alert).length
|
||||
return `${detail.trader.name} · ${tags} · ${seatCount}席位 · ${stockCount}股票 · ${warningCount}预警`
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
const detail = props.traderDetail
|
||||
if (!detail) return []
|
||||
return [
|
||||
{ label: '近期参与股票数', value: detail.stocks.length, tone: '' },
|
||||
{
|
||||
label: '当前卖出预警',
|
||||
value: detail.stocks.filter((item) => item.has_sell_alert).length,
|
||||
tone: 'danger',
|
||||
},
|
||||
{
|
||||
label: '慢流出观察',
|
||||
value: detail.stocks.filter((item) => item.has_slow_exit).length,
|
||||
tone: 'watch',
|
||||
},
|
||||
{ label: '核心席位', value: detail.seats.length, tone: 'focus' },
|
||||
{ label: '股票数', value: detail.stocks.length, tone: '' },
|
||||
{ label: '卖出预警', value: detail.stocks.filter((item) => item.has_sell_alert).length, tone: 'danger' },
|
||||
{ label: '慢流出', value: detail.stocks.filter((item) => item.has_slow_exit).length, tone: 'watch' },
|
||||
{ label: '席位数', value: detail.seats.length, tone: 'focus' },
|
||||
]
|
||||
})
|
||||
|
||||
const filteredStocks = computed(() => {
|
||||
const detail = props.traderDetail
|
||||
if (!detail) return []
|
||||
|
||||
const keyword = stockNameFilter.value.trim().toLowerCase()
|
||||
const filtered = detail.stocks.filter((stock) => {
|
||||
const matchName =
|
||||
!keyword ||
|
||||
stock.stock_name.toLowerCase().includes(keyword) ||
|
||||
stock.stock_code.toLowerCase().includes(keyword)
|
||||
const matchDate = !selectedDateFilter.value || stock.last_trade_date === selectedDateFilter.value
|
||||
const matchNetGrowth = netGrowthFilter.value === 'all' || stock.is_net_amount_increasing
|
||||
return matchName && matchDate && matchNetGrowth
|
||||
})
|
||||
|
||||
const sorted = [...filtered]
|
||||
sorted.sort((left, right) => {
|
||||
if (sortKey.value === 'total_net_amount_wan') {
|
||||
return (right.total_net_amount_wan ?? 0) - (left.total_net_amount_wan ?? 0)
|
||||
}
|
||||
if (sortKey.value === 'action_count') {
|
||||
return right.action_count - left.action_count
|
||||
}
|
||||
return (right.last_trade_date || '').localeCompare(left.last_trade_date || '')
|
||||
})
|
||||
return sorted
|
||||
})
|
||||
|
||||
const availableDates = computed(() => {
|
||||
const detail = props.traderDetail
|
||||
if (!detail) return []
|
||||
return [...new Set(detail.stocks.map((item) => item.last_trade_date).filter(Boolean))] as string[]
|
||||
})
|
||||
|
||||
function netTone(stock: TraderStock) {
|
||||
return priceTone(String(stock.total_net_amount_wan ?? ''))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -48,7 +95,7 @@ const summaryCards = computed(() => {
|
||||
<div>
|
||||
<p class="screen-kicker">02 Trader Detail</p>
|
||||
<h2 class="screen-title">
|
||||
游资详情页
|
||||
游资详情
|
||||
<span v-if="traderDetail">· {{ traderDetail.trader.name }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
@ -67,76 +114,113 @@ const summaryCards = computed(() => {
|
||||
</header>
|
||||
|
||||
<div v-if="traderDetail" class="trader-layout">
|
||||
<div class="header-card">
|
||||
<article class="profile-card">
|
||||
<h3 class="profile-title">
|
||||
{{ traderDetail.trader.name }}
|
||||
<span v-if="traderDetail.trader.alias_name">
|
||||
/ {{ traderDetail.trader.alias_name }}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="profile-desc">{{ profileText }}</p>
|
||||
<div class="chip-list">
|
||||
<span
|
||||
v-for="seat in traderDetail.seats"
|
||||
:key="seat.seat_name"
|
||||
class="chip"
|
||||
>
|
||||
{{ seat.seat_name }}
|
||||
</span>
|
||||
<article class="profile-card compact">
|
||||
<div class="profile-head compact">
|
||||
<div class="profile-line">
|
||||
<h3 class="profile-title compact">
|
||||
{{ traderDetail.trader.name }}
|
||||
<span v-if="traderDetail.trader.alias_name">/ {{ traderDetail.trader.alias_name }}</span>
|
||||
</h3>
|
||||
<p class="profile-desc compact">{{ compactProfileText }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
class="summary-box"
|
||||
:class="item.tone"
|
||||
>
|
||||
<p class="summary-label">{{ item.label }}</p>
|
||||
<h3 class="summary-value">{{ item.value }}</h3>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="card-panel">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">近期参与股票列表</h3>
|
||||
<span class="pill">点击任意股票进入详情</span>
|
||||
</div>
|
||||
|
||||
<div class="stock-table">
|
||||
<button
|
||||
v-for="stock in traderDetail.stocks.slice(0, 18)"
|
||||
:key="stock.stock_code"
|
||||
class="stock-row"
|
||||
type="button"
|
||||
@click="emit('selectStock', stock.stock_code)"
|
||||
>
|
||||
<div class="stock-core">
|
||||
<strong>{{ stock.stock_name }}</strong>
|
||||
<span>{{ stock.stock_code }}</span>
|
||||
<div class="summary-inline">
|
||||
<div
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
class="summary-chip"
|
||||
:class="item.tone"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
<div>{{ stock.latest_price ?? '-' }}</div>
|
||||
<div :class="priceTone(stock.pct_chg)">{{ stock.pct_chg ?? '-' }}</div>
|
||||
<div>{{ stock.action_count }} 次动作</div>
|
||||
<div>{{ formatDate(stock.last_trade_date) }}</div>
|
||||
<div class="tag-group">
|
||||
<span v-if="stock.has_sell_alert" class="tag red">卖出预警</span>
|
||||
<span v-if="stock.has_slow_exit" class="tag orange">慢流出</span>
|
||||
<span v-if="!stock.has_sell_alert && !stock.has_slow_exit" class="tag blue">跟踪中</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="content-grid">
|
||||
<article class="card-panel stock-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3 class="section-title">股票列表</h3>
|
||||
<p class="section-caption">支持时间、名称、净额趋势筛选,并展示行业、上市板块、总市值和净额。</p>
|
||||
</div>
|
||||
<span class="pill">{{ filteredStocks.length }} 只</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<label class="filter-item">
|
||||
<span>时间</span>
|
||||
<select v-model="selectedDateFilter">
|
||||
<option value="">全部时间</option>
|
||||
<option v-for="date in availableDates" :key="date" :value="date">{{ date }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-item">
|
||||
<span>名称</span>
|
||||
<input v-model="stockNameFilter" type="text" placeholder="股票名称 / 代码">
|
||||
</label>
|
||||
|
||||
<label class="filter-item">
|
||||
<span>排序</span>
|
||||
<select v-model="sortKey">
|
||||
<option value="last_trade_date">按时间</option>
|
||||
<option value="total_net_amount_wan">按净额</option>
|
||||
<option value="action_count">按动作数</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-item">
|
||||
<span>净额趋势</span>
|
||||
<select v-model="netGrowthFilter">
|
||||
<option value="all">全部趋势</option>
|
||||
<option value="increasing">净额连续增大</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stock-table">
|
||||
<button
|
||||
v-for="stock in filteredStocks"
|
||||
:key="stock.stock_code"
|
||||
class="stock-row"
|
||||
type="button"
|
||||
@click="emit('selectStock', stock.stock_code)"
|
||||
>
|
||||
<div class="stock-core">
|
||||
<strong>{{ stock.stock_name }}</strong>
|
||||
<span>{{ stock.stock_code }}</span>
|
||||
</div>
|
||||
<div>{{ stock.industry || '行业待补充' }}</div>
|
||||
<div>{{ inferBoardLabel(stock.stock_code) }}</div>
|
||||
<div>{{ compactMoney(stock.total_market_value) }}</div>
|
||||
<div>{{ stock.latest_price ?? '-' }}</div>
|
||||
<div :class="priceTone(stock.pct_chg)">{{ stock.pct_chg ?? '-' }}</div>
|
||||
<div class="net-value-cell" :class="netTone(stock)">{{ formatSignedWanAmount(stock.total_net_amount_wan) }}</div>
|
||||
<div>{{ formatDate(stock.last_trade_date) }}</div>
|
||||
<div class="tag-group">
|
||||
<span v-if="stock.has_sell_alert" class="tag red">卖出预警</span>
|
||||
<span v-if="stock.has_slow_exit" class="tag orange">慢流出</span>
|
||||
<span v-if="stock.is_net_amount_increasing" class="tag gold">净额递增</span>
|
||||
<span v-if="!stock.has_sell_alert && !stock.has_slow_exit" class="tag blue">跟踪中</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
|
||||
@ -144,21 +228,22 @@ const summaryCards = computed(() => {
|
||||
}
|
||||
|
||||
.screen-head,
|
||||
.section-head {
|
||||
.section-head,
|
||||
.profile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.screen-kicker {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-gold-soft);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@ -171,30 +256,28 @@ const summaryCards = computed(() => {
|
||||
|
||||
.screen-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.screen-toolbar,
|
||||
.chip-list,
|
||||
.tag-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.pill-button,
|
||||
.chip,
|
||||
.tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.pill-button {
|
||||
padding: 8px 12px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
@ -208,97 +291,155 @@ const summaryCards = computed(() => {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
.trader-layout,
|
||||
.content-grid,
|
||||
.stock-table {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trader-layout {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.summary-box,
|
||||
.card-panel,
|
||||
.stock-row {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.card-panel {
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(212, 163, 92, 0.18), rgba(255, 255, 255, 0.02));
|
||||
background: linear-gradient(135deg, rgba(212, 163, 92, 0.16), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.profile-card.compact {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 34px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.profile-desc {
|
||||
margin: 10px 0 16px;
|
||||
color: #d6ceb9;
|
||||
line-height: 1.8;
|
||||
.profile-title.compact {
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-desc,
|
||||
.section-caption {
|
||||
margin: 6px 0 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.profile-desc.compact {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profile-head.compact {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-line {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.summary-chip strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.summary-box.danger .summary-value {
|
||||
.summary-chip.danger strong {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.summary-box.watch .summary-value {
|
||||
.summary-chip.watch strong {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.summary-box.focus .summary-value {
|
||||
.summary-chip.focus strong {
|
||||
color: var(--color-gold-soft);
|
||||
}
|
||||
|
||||
.card-panel {
|
||||
padding: 18px;
|
||||
.stock-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter-item select,
|
||||
.filter-item input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: var(--color-text);
|
||||
background: rgba(8, 12, 18, 0.95);
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.stock-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.7fr 0.7fr 0.8fr 0.9fr 1fr;
|
||||
grid-template-columns: 1.45fr 0.75fr 0.92fr 0.9fr 0.72fr 0.72fr 0.95fr 0.9fr 1fr;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
@ -309,21 +450,35 @@ const summaryCards = computed(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stock-core strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stock-core span {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.net-value-cell {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 74px;
|
||||
min-width: 66px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@ -339,6 +494,12 @@ const summaryCards = computed(() => {
|
||||
border-color: rgba(255, 174, 66, 0.18);
|
||||
}
|
||||
|
||||
.tag.gold {
|
||||
color: var(--color-gold-soft);
|
||||
background: rgba(212, 163, 92, 0.16);
|
||||
border-color: rgba(212, 163, 92, 0.24);
|
||||
}
|
||||
|
||||
.tag.blue {
|
||||
color: #acd8ff;
|
||||
background: rgba(90, 184, 255, 0.14);
|
||||
@ -357,15 +518,27 @@ const summaryCards = computed(() => {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.header-card {
|
||||
@media (max-width: 1280px) {
|
||||
.content-grid,
|
||||
.filter-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.stock-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.profile-head.compact {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-inline {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -20,6 +20,13 @@ const countsByType = computed(() => {
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
const countsByTrader = computed(() => {
|
||||
return props.warnings.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.trader_name] = (acc[item.trader_name] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -41,14 +48,14 @@ const countsByType = computed(() => {
|
||||
<article class="filter-box">
|
||||
<h3 class="section-title">游资筛选</h3>
|
||||
<div
|
||||
v-for="trader in traders"
|
||||
:key="trader.id"
|
||||
class="check-row"
|
||||
>
|
||||
<span>{{ trader.name }}</span>
|
||||
<strong>{{ trader.stock_count }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
v-for="trader in traders"
|
||||
:key="trader.id"
|
||||
class="check-row"
|
||||
>
|
||||
<span>{{ trader.name }}</span>
|
||||
<strong>{{ countsByTrader[trader.name] ?? 0 }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="filter-box">
|
||||
<h3 class="section-title">预警类型</h3>
|
||||
|
||||
@ -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,21 @@ 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
|
||||
const watchlistCodeSet = new Set(watchlistResult.map((item) => item.stock_code))
|
||||
const preferredWarningCode =
|
||||
warningResult.find((item) => watchlistCodeSet.has(item.stock_code))?.stock_code ??
|
||||
warningResult[0]?.stock_code ??
|
||||
''
|
||||
const preferredStockCode = watchlist.value[0]?.stock_code ?? preferredWarningCode
|
||||
|
||||
if (preferredWarningCode) {
|
||||
selectedWarningCode.value = preferredWarningCode
|
||||
}
|
||||
if (preferredStockCode) {
|
||||
selectedWarningCode.value = preferredStockCode
|
||||
await selectStock(preferredStockCode)
|
||||
selectedStockCode.value = preferredStockCode
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = String(error instanceof Error ? error.message : error)
|
||||
|
||||
@ -72,8 +72,14 @@ export interface TraderStock {
|
||||
last_trade_date: string | null
|
||||
buy_action_count: number
|
||||
sell_action_count: number
|
||||
total_net_amount_wan?: number | null
|
||||
has_sell_alert: number
|
||||
has_slow_exit: number
|
||||
is_net_amount_increasing?: boolean
|
||||
industry?: string | null
|
||||
market?: string | null
|
||||
total_market_value?: number | null
|
||||
circulating_market_value?: number | null
|
||||
}
|
||||
|
||||
export interface TraderDetail {
|
||||
|
||||
@ -11,9 +11,10 @@ export function warningTone(level: string): 'red' | 'orange' | 'gold' {
|
||||
}
|
||||
|
||||
export function priceTone(value: string | null | undefined): 'rise' | 'fall' | 'flat' {
|
||||
if (!value) return 'flat'
|
||||
if (String(value).startsWith('-')) return 'fall'
|
||||
if (String(value).startsWith('+') || Number(value) > 0) return 'rise'
|
||||
const parsed = numberFromText(value)
|
||||
if (parsed === null) return 'flat'
|
||||
if (parsed < 0) return 'fall'
|
||||
if (parsed > 0) return 'rise'
|
||||
return 'flat'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user