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/
|
||||||
frontend/.vite-temp/
|
frontend/.vite-temp/
|
||||||
|
|
||||||
# Sensitive or machine-local config
|
|
||||||
backend/config.yaml
|
|
||||||
|
|
||||||
# Temp / debug files
|
# Temp / debug files
|
||||||
_tmp_*.json
|
_tmp_*.json
|
||||||
_curl_*.json
|
_curl_*.json
|
||||||
|
|||||||
28
README.md
28
README.md
@ -23,6 +23,21 @@
|
|||||||
- 关注池写入数据库,支持新增和删除。
|
- 关注池写入数据库,支持新增和删除。
|
||||||
- 个股详情支持 K 线、MA5、买卖点与预警信息展示。
|
- 个股详情支持 K 线、MA5、买卖点与预警信息展示。
|
||||||
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
||||||
|
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
|
||||||
|
|
||||||
|
## 最近界面与数据调整
|
||||||
|
|
||||||
|
- 个股详情页的“买卖明细”已改为“买卖力度趋势”图:
|
||||||
|
- 柱形图按日期展示买入和卖出,买入为正、卖出为负。
|
||||||
|
- 折线展示“当日净额”和“累计净额”。
|
||||||
|
- 明细仅在鼠标悬浮图表时显示。
|
||||||
|
- 个股详情页顶部新增“预警列表”按钮:
|
||||||
|
- 有预警时红色高亮提示。
|
||||||
|
- 点击后以弹层方式展示当前个股预警,不再挤占右侧图表区域。
|
||||||
|
- 个股详情、首页候选股、游资详情页的数据补全逻辑已增强:
|
||||||
|
- 优先读取数据库中的股票元数据。
|
||||||
|
- 外部快照失败时增加备用行情源兜底。
|
||||||
|
- 收盘后更新流程会同步补全行业、市值、流通市值等字段。
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
@ -35,6 +50,7 @@
|
|||||||
1. 复制配置文件:
|
1. 复制配置文件:
|
||||||
- 将 `backend/config.example.yaml` 复制为 `backend/config.yaml`
|
- 将 `backend/config.example.yaml` 复制为 `backend/config.yaml`
|
||||||
- 按实际数据库连接信息修改
|
- 按实际数据库连接信息修改
|
||||||
|
- 当前仓库已允许提交 `backend/config.yaml`,服务器部署默认读取该文件
|
||||||
2. 初始化数据库:
|
2. 初始化数据库:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@ -47,6 +63,18 @@ python backend/scripts/init_db.py
|
|||||||
python backend/scripts/run_api.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`
|
- `http://127.0.0.1:8000`
|
||||||
|
|||||||
@ -35,6 +35,15 @@ monitoring:
|
|||||||
- 5
|
- 5
|
||||||
turnover_warning_threshold: 0.30
|
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:
|
traders:
|
||||||
- name: "章盟主"
|
- name: "章盟主"
|
||||||
alias: "章建平"
|
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",
|
"fastapi>=0.116.1",
|
||||||
"uvicorn>=0.35.0",
|
"uvicorn>=0.35.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
|
"reportlab>=4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[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(
|
cursor.execute(
|
||||||
"ALTER TABLE lhb_detail_seats ADD UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)"
|
"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:
|
def main() -> None:
|
||||||
|
|||||||
@ -23,6 +23,16 @@ class DatabaseConfig:
|
|||||||
connect_timeout_seconds: int = 10
|
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:
|
class AppConfig:
|
||||||
def __init__(self, raw: dict[str, Any], path: Path) -> None:
|
def __init__(self, raw: dict[str, Any], path: Path) -> None:
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
@ -55,9 +65,22 @@ class AppConfig:
|
|||||||
def data_sources(self) -> dict[str, Any]:
|
def data_sources(self) -> dict[str, Any]:
|
||||||
return self.raw.get("data_sources", {})
|
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:
|
def load_config(path: str | Path | None = None) -> AppConfig:
|
||||||
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
return AppConfig(raw=raw, path=config_path)
|
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 .db import db_cursor
|
||||||
from .sources.eastmoney import EastMoneyClient
|
from .sources.eastmoney import EastMoneyClient
|
||||||
from .sources.sina import SinaClient
|
from .sources.sina import SinaClient
|
||||||
|
from .sources.tencent import TencentClient
|
||||||
|
|
||||||
|
|
||||||
def _normalize_value(value: Any) -> Any:
|
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:
|
def _infer_market_label(stock_code: str) -> str:
|
||||||
if stock_code.startswith(("6", "9", "5", "688")):
|
if stock_code.startswith(("6", "9", "5", "688")):
|
||||||
return "沪A"
|
return "\u6caaA"
|
||||||
return "深A"
|
return "\u6df1A"
|
||||||
|
|
||||||
|
|
||||||
def _infer_board_label(stock_code: str) -> str:
|
def _infer_board_label(stock_code: str) -> str:
|
||||||
if stock_code.startswith(("688", "689")):
|
if stock_code.startswith(("688", "689")):
|
||||||
return "绉戝垱鏉?"
|
return "\u79d1\u521b\u677f"
|
||||||
if stock_code.startswith(("300", "301")):
|
if stock_code.startswith(("300", "301")):
|
||||||
return "鍒涗笟鏉?"
|
return "\u521b\u4e1a\u677f"
|
||||||
if stock_code.startswith(("8", "4", "920")):
|
if stock_code.startswith(("8", "4", "920")):
|
||||||
return "鍖椾氦鎵€"
|
return "\u5317\u4ea4\u6240"
|
||||||
if stock_code.startswith(("60", "601", "603", "605", "900")):
|
if stock_code.startswith(("60", "601", "603", "605", "900")):
|
||||||
return "娌富鏉?"
|
return "\u6caa\u4e3b\u677f"
|
||||||
if stock_code.startswith(("000", "001", "002", "003", "200")):
|
if stock_code.startswith(("000", "001", "002", "003", "200")):
|
||||||
return "娣变富鏉?"
|
return "\u6df1\u4e3b\u677f"
|
||||||
return "A鑲?"
|
return "A\u80a1"
|
||||||
|
|
||||||
|
|
||||||
def fetch_summary() -> dict[str, Any]:
|
def fetch_summary() -> dict[str, Any]:
|
||||||
@ -121,15 +122,29 @@ def fetch_traders() -> list[dict[str, Any]]:
|
|||||||
t.alias_name,
|
t.alias_name,
|
||||||
t.warning_weight,
|
t.warning_weight,
|
||||||
t.style_tags,
|
t.style_tags,
|
||||||
COUNT(DISTINCT d.stock_code) AS stock_count,
|
COALESCE(ds.stock_count, 0) 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,
|
COALESCE(ws.sell_alert_count, 0) 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(ws.slow_exit_count, 0) AS slow_exit_count
|
||||||
FROM traders t
|
FROM traders t
|
||||||
LEFT JOIN lhb_detail_seats d
|
LEFT JOIN (
|
||||||
ON d.matched_trader_name = t.name
|
SELECT
|
||||||
LEFT JOIN warning_events w
|
matched_trader_name,
|
||||||
ON w.trader_name = t.name
|
COUNT(DISTINCT stock_code) AS stock_count
|
||||||
GROUP BY t.id, t.name, t.alias_name, t.warning_weight, t.style_tags
|
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
|
ORDER BY stock_count DESC, t.name
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@ -274,6 +289,7 @@ def fetch_trader_actions(
|
|||||||
o.price AS current_price,
|
o.price AS current_price,
|
||||||
o.pct_chg,
|
o.pct_chg,
|
||||||
s.industry,
|
s.industry,
|
||||||
|
s.concept_tags,
|
||||||
s.market,
|
s.market,
|
||||||
s.total_market_value,
|
s.total_market_value,
|
||||||
s.circulating_market_value,
|
s.circulating_market_value,
|
||||||
@ -313,6 +329,7 @@ def fetch_trader_actions(
|
|||||||
actions = [_normalize_row(row) for row in cursor.fetchall()]
|
actions = [_normalize_row(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
for action in actions:
|
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["market"] = action.get("market") or _infer_market_label(action["stock_code"])
|
||||||
action["board_label"] = _infer_board_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()]
|
seats = [_normalize_row(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
cursor.execute(
|
stock_query = """
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
d.stock_code,
|
d.stock_code,
|
||||||
MAX(COALESCE(o.stock_name, d.stock_name)) AS stock_name,
|
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,
|
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.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(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 = '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
|
FROM lhb_detail_seats d
|
||||||
LEFT JOIN lhb_overview o
|
LEFT JOIN lhb_overview o
|
||||||
ON o.stock_code = d.stock_code AND o.trade_date = d.trade_date
|
ON o.stock_code = d.stock_code AND o.trade_date = d.trade_date
|
||||||
LEFT JOIN warning_events w
|
LEFT JOIN warning_events w
|
||||||
ON w.stock_code = d.stock_code AND w.trader_name = d.matched_trader_name
|
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
|
WHERE d.matched_trader_name = %s
|
||||||
GROUP BY d.stock_code
|
GROUP BY d.stock_code
|
||||||
ORDER BY last_trade_date DESC, action_count DESC
|
ORDER BY last_trade_date DESC, action_count DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
""",
|
"""
|
||||||
(trader_name,),
|
|
||||||
)
|
cursor.execute(stock_query, (trader_name,))
|
||||||
stocks = [_normalize_row(row) for row in cursor.fetchall()]
|
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(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT trade_date, stock_code, stock_name, warning_type, warning_level, trigger_reason
|
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)
|
quote_snapshot = eastmoney.fetch_quote_snapshot(stock_code)
|
||||||
except Exception:
|
except Exception:
|
||||||
quote_snapshot = {}
|
quote_snapshot = {}
|
||||||
|
if not quote_snapshot:
|
||||||
|
try:
|
||||||
|
quote_snapshot = TencentClient().fetch_quote_snapshot(stock_code)
|
||||||
|
except Exception:
|
||||||
|
quote_snapshot = {}
|
||||||
if not market_daily:
|
if not market_daily:
|
||||||
try:
|
try:
|
||||||
market_daily = SinaClient().fetch_daily_kline(stock_code)
|
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_code (stock_code),
|
||||||
KEY idx_lhb_detail_trade_date (trade_date),
|
KEY idx_lhb_detail_trade_date (trade_date),
|
||||||
KEY idx_lhb_detail_trader (matched_trader_name),
|
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)
|
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,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
KEY idx_warning_events_code (stock_code),
|
KEY idx_warning_events_code (stock_code),
|
||||||
KEY idx_warning_events_trade_date (trade_date),
|
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 (
|
CREATE TABLE IF NOT EXISTS watchlist_entries (
|
||||||
|
|||||||
@ -21,7 +21,53 @@ def infer_secid(stock_code: str) -> str:
|
|||||||
return f"0.{stock_code}"
|
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:
|
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]:
|
def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]:
|
||||||
secid = infer_secid(stock_code)
|
secid = infer_secid(stock_code)
|
||||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
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 页面组件
|
### 4.3 页面组件
|
||||||
|
|
||||||
- `HomeControlScreen.vue`:首页总控台
|
- `HomeControlScreen.vue`:首页总控台
|
||||||
- `TraderDetailScreen.vue`:游资详情
|
- `TraderDetailScreen.vue`:游资详情,采用单列全宽股票列表,支持时间、名称、净额连续增大筛选,并展示行业、板块、总市值、净额和预警标签
|
||||||
- `StockDetailScreen.vue`:个股详情
|
- `StockDetailScreen.vue`:个股详情
|
||||||
- `WarningCenterScreen.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 预警中心
|
### 2.6 预警中心
|
||||||
|
|
||||||
@ -152,7 +154,48 @@
|
|||||||
- 关键页面在 1440px 及以上宽度下保持清晰稳定
|
- 关键页面在 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. 首页顶部指标与关注列表联动,取消关注后自动剔除统计。
|
6. 首页顶部指标与关注列表联动,取消关注后自动剔除统计。
|
||||||
7. 所有股票入口都能跳转到股票详情。
|
7. 所有股票入口都能跳转到股票详情。
|
||||||
8. 游资详情页去掉动作时间线。
|
8. 游资详情页去掉动作时间线。
|
||||||
|
9. 游资详情页去掉净额重点和席位概览侧栏,保留单列股票列表和净额趋势筛选。
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>顶级游资监控系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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">
|
<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 AppHero from './components/AppHero.vue'
|
||||||
import HomeControlScreen from './components/HomeControlScreen.vue'
|
import HomeControlScreen from './components/HomeControlScreen.vue'
|
||||||
@ -33,12 +33,25 @@ function syncPageFromHash() {
|
|||||||
currentPage.value = pageFromHash()
|
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) {
|
function navigate(page: PageKey) {
|
||||||
const nextHash = `#/${page}`
|
const nextHash = `#/${page}`
|
||||||
if (window.location.hash !== nextHash) {
|
if (window.location.hash !== nextHash) {
|
||||||
window.location.hash = nextHash
|
window.location.hash = nextHash
|
||||||
}
|
}
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
void ensurePageData(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSelectTrader(traderId: number) {
|
async function handleSelectTrader(traderId: number) {
|
||||||
@ -84,12 +97,19 @@ async function handleUnfollowStock(stockCode: string) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncPageFromHash()
|
syncPageFromHash()
|
||||||
window.addEventListener('hashchange', syncPageFromHash)
|
window.addEventListener('hashchange', syncPageFromHash)
|
||||||
void dashboard.initialize()
|
void (async () => {
|
||||||
|
await dashboard.initialize()
|
||||||
|
await ensurePageData(currentPage.value)
|
||||||
|
})()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('hashchange', syncPageFromHash)
|
window.removeEventListener('hashchange', syncPageFromHash)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(currentPage, (page) => {
|
||||||
|
void ensurePageData(page)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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 type { StockDetail, WarningItem } from '../types'
|
||||||
import {
|
import {
|
||||||
compactMoney,
|
compactMoney,
|
||||||
@ -44,8 +45,10 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const chartRef = useTemplateRef<SVGSVGElement>('chartRef')
|
const chartRef = useTemplateRef<SVGSVGElement>('chartRef')
|
||||||
|
const warningPopoverRef = useTemplateRef<HTMLDivElement>('warningPopoverRef')
|
||||||
const selectedZoom = shallowRef(80)
|
const selectedZoom = shallowRef(80)
|
||||||
const hoveredIndex = shallowRef<number | null>(null)
|
const hoveredIndex = shallowRef<number | null>(null)
|
||||||
|
const showWarningPanel = shallowRef(false)
|
||||||
|
|
||||||
const zoomOptions = [
|
const zoomOptions = [
|
||||||
{ label: '近40日', count: 40 },
|
{ label: '近40日', count: 40 },
|
||||||
@ -60,11 +63,6 @@ const chartLegendItems = [
|
|||||||
{ label: 'S 卖出', tone: '#5ab8ff', type: 'dot' },
|
{ label: 'S 卖出', tone: '#5ab8ff', type: 'dot' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function validText(value: string | null | undefined): string | null {
|
|
||||||
if (!value || value === '-') return null
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercentNumber(value: number | null | undefined): string {
|
function formatPercentNumber(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return '-'
|
if (value === null || value === undefined) return '-'
|
||||||
return `${value.toFixed(2)}%`
|
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 topWarning = computed(() => props.activeWarning ?? props.stockDetail?.warnings[0] ?? null)
|
||||||
|
const allWarnings = computed(() => props.stockDetail?.warnings ?? [])
|
||||||
const latestOverview = computed(() => props.stockDetail?.overview?.[0] ?? null)
|
const latestOverview = computed(() => props.stockDetail?.overview?.[0] ?? null)
|
||||||
const displayedTraderSummary = computed(() => (props.stockDetail?.trader_summary ?? []).slice(0, 4))
|
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(() => {
|
const totalNetAmountWan = computed(() => {
|
||||||
return (props.stockDetail?.trader_summary ?? []).reduce((sum, item) => sum + (item.total_net_amount_wan ?? 0), 0)
|
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
|
const latest = latestOverview.value
|
||||||
if (!stock) return []
|
if (!stock) return []
|
||||||
|
|
||||||
const latestPrice = snapshot?.latest_price ?? numberFromText(latest?.price) ?? null
|
const latestPriceValue =
|
||||||
const latestPct = snapshot?.pct_chg ?? numberFromText(validText(latest?.pct_chg)) ?? null
|
snapshot?.latest_price != null
|
||||||
const latestAmount = snapshot?.amount ?? numberFromText(latest?.amount) ?? 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 [
|
return [
|
||||||
{ label: '代码', value: stock.stock_code },
|
{ label: '代码', value: stock.stock_code },
|
||||||
{ label: '市场', value: stock.market || 'A股' },
|
{ label: '市场', value: stock.market || 'A股' },
|
||||||
{ label: '行业', value: stock.industry || snapshot?.industry || '-' },
|
{ label: '行业', value: stock.industry || snapshot?.industry || '-' },
|
||||||
{ label: '最新价', value: latestPrice === null ? '-' : String((latestPrice / 100).toFixed(2)) },
|
{ label: '最新价', value: latestPriceValue },
|
||||||
{ label: '涨跌幅', value: latestPct === null ? valueOrFallback(latest?.pct_chg, '-') : formatPercentNumber(latestPct / 100) },
|
{ label: '涨跌幅', value: latestPctValue },
|
||||||
{ label: '成交额', value: latestAmount === null ? valueOrFallback(latest?.amount, '-') : formatAmountNumber(latestAmount) },
|
{ label: '成交额', value: latestAmountValue },
|
||||||
{ label: '振幅', value: snapshot?.amplitude == null ? '-' : formatPercentNumber(snapshot.amplitude / 100) },
|
{ label: '振幅', value: snapshot?.amplitude == null ? '-' : formatPercentNumber(snapshot.amplitude / 100) },
|
||||||
{ label: '换手', value: snapshot?.turnover == null ? '-' : formatPercentNumber(snapshot.turnover / 100) },
|
{ label: '换手', value: snapshot?.turnover == null ? '-' : formatPercentNumber(snapshot.turnover / 100) },
|
||||||
{ label: '总市值', value: compactMoney(stock.total_market_value ?? snapshot?.total_market_value ?? null) },
|
{ label: '总市值', value: compactMoney(stock.total_market_value ?? snapshot?.total_market_value ?? null) },
|
||||||
@ -294,6 +300,31 @@ function handleChartMove(event: MouseEvent) {
|
|||||||
function clearHover() {
|
function clearHover() {
|
||||||
hoveredIndex.value = null
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -308,6 +339,42 @@ function clearHover() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="screen-toolbar">
|
<div class="screen-toolbar">
|
||||||
<span class="pill active">日K + 操作观点</span>
|
<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="topWarning">{{ warningLabel(topWarning.warning_type) }}</span>
|
||||||
<span class="pill" v-if="stockDetail">{{ stockDetail.stock.stock_code }}</span>
|
<span class="pill" v-if="stockDetail">{{ stockDetail.stock.stock_code }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -515,46 +582,8 @@ function clearHover() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="side-card">
|
<article class="side-card action-chart-card">
|
||||||
<div class="section-head">
|
<StockActionTimelineChart :actions="stockDetail.trader_actions" />
|
||||||
<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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -564,7 +593,7 @@ function clearHover() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.screen {
|
.screen {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--color-line);
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
@ -610,6 +639,7 @@ function clearHover() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill,
|
.pill,
|
||||||
@ -638,6 +668,83 @@ function clearHover() {
|
|||||||
cursor: pointer;
|
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 {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@ -688,6 +795,8 @@ function clearHover() {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
height: calc(100% - 52px);
|
height: calc(100% - 52px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-stack,
|
.main-stack,
|
||||||
@ -702,7 +811,7 @@ function clearHover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-stack {
|
.side-stack {
|
||||||
grid-template-rows: auto repeat(3, minmax(0, 1fr));
|
grid-template-rows: auto auto minmax(280px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-panel,
|
.card-panel,
|
||||||
@ -840,9 +949,7 @@ function clearHover() {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.judge-desc,
|
.judge-desc {
|
||||||
.timeline-desc,
|
|
||||||
.detail-desc {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #d6ceb9;
|
color: #d6ceb9;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@ -862,16 +969,12 @@ function clearHover() {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trader-flow,
|
.trader-flow {
|
||||||
.detail-item,
|
|
||||||
.timeline-entry {
|
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trader-flow:last-child,
|
.trader-flow:last-child {
|
||||||
.detail-item:last-child,
|
|
||||||
.timeline-entry:last-child {
|
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -906,6 +1009,10 @@ function clearHover() {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-chart-card {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
|
|
||||||
import type { TraderDetail, TraderListItem } from '../types'
|
import type { TraderDetail, TraderListItem, TraderStock } from '../types'
|
||||||
import { formatDate, priceTone } from '../utils/format'
|
import { compactMoney, formatDate, formatSignedWanAmount, priceTone } from '../utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
traders: TraderListItem[]
|
traders: TraderListItem[]
|
||||||
@ -15,31 +15,78 @@ const emit = defineEmits<{
|
|||||||
selectStock: [stockCode: string]
|
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
|
const detail = props.traderDetail
|
||||||
if (!detail) return '等待选择游资。'
|
if (!detail) return ''
|
||||||
const tags = detail.trader.style_tags?.join('、') || '暂无风格标签'
|
const tags = detail.trader.style_tags?.slice(0, 3).join(' / ') || '暂无标签'
|
||||||
return `${detail.trader.name} 当前风格聚焦 ${tags}。本页只保留游资档案和近期参与股票列表,动作时间线已移除。`
|
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 summaryCards = computed(() => {
|
||||||
const detail = props.traderDetail
|
const detail = props.traderDetail
|
||||||
if (!detail) return []
|
if (!detail) return []
|
||||||
return [
|
return [
|
||||||
{ label: '近期参与股票数', value: detail.stocks.length, tone: '' },
|
{ label: '股票数', value: detail.stocks.length, tone: '' },
|
||||||
{
|
{ label: '卖出预警', value: detail.stocks.filter((item) => item.has_sell_alert).length, tone: 'danger' },
|
||||||
label: '当前卖出预警',
|
{ label: '慢流出', value: detail.stocks.filter((item) => item.has_slow_exit).length, tone: 'watch' },
|
||||||
value: detail.stocks.filter((item) => item.has_sell_alert).length,
|
{ label: '席位数', value: detail.seats.length, tone: 'focus' },
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -48,7 +95,7 @@ const summaryCards = computed(() => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="screen-kicker">02 Trader Detail</p>
|
<p class="screen-kicker">02 Trader Detail</p>
|
||||||
<h2 class="screen-title">
|
<h2 class="screen-title">
|
||||||
游资详情页
|
游资详情
|
||||||
<span v-if="traderDetail">· {{ traderDetail.trader.name }}</span>
|
<span v-if="traderDetail">· {{ traderDetail.trader.name }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -67,76 +114,113 @@ const summaryCards = computed(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="traderDetail" class="trader-layout">
|
<div v-if="traderDetail" class="trader-layout">
|
||||||
<div class="header-card">
|
<article class="profile-card compact">
|
||||||
<article class="profile-card">
|
<div class="profile-head compact">
|
||||||
<h3 class="profile-title">
|
<div class="profile-line">
|
||||||
{{ traderDetail.trader.name }}
|
<h3 class="profile-title compact">
|
||||||
<span v-if="traderDetail.trader.alias_name">
|
{{ traderDetail.trader.name }}
|
||||||
/ {{ traderDetail.trader.alias_name }}
|
<span v-if="traderDetail.trader.alias_name">/ {{ traderDetail.trader.alias_name }}</span>
|
||||||
</span>
|
</h3>
|
||||||
</h3>
|
<p class="profile-desc compact">{{ compactProfileText }}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
|
||||||
<div class="summary-grid">
|
<div class="summary-inline">
|
||||||
<article
|
<div
|
||||||
v-for="item in summaryCards"
|
v-for="item in summaryCards"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
class="summary-box"
|
class="summary-chip"
|
||||||
:class="item.tone"
|
:class="item.tone"
|
||||||
>
|
>
|
||||||
<p class="summary-label">{{ item.label }}</p>
|
<span>{{ item.label }}</span>
|
||||||
<h3 class="summary-value">{{ item.value }}</h3>
|
<strong>{{ item.value }}</strong>
|
||||||
</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>
|
</div>
|
||||||
<div>{{ stock.latest_price ?? '-' }}</div>
|
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.screen {
|
.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: 1px solid var(--color-line);
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
|
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,
|
.screen-head,
|
||||||
.section-head {
|
.section-head,
|
||||||
|
.profile-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-head {
|
.screen-head {
|
||||||
align-items: center;
|
margin-bottom: 0;
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-kicker {
|
.screen-kicker {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: var(--color-gold-soft);
|
color: var(--color-gold-soft);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
letter-spacing: 0.22em;
|
letter-spacing: 0.22em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@ -171,30 +256,28 @@ const summaryCards = computed(() => {
|
|||||||
|
|
||||||
.screen-title {
|
.screen-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-toolbar,
|
.screen-toolbar,
|
||||||
.chip-list,
|
|
||||||
.tag-group {
|
.tag-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill,
|
.pill,
|
||||||
.pill-button,
|
.pill-button,
|
||||||
.chip,
|
|
||||||
.tag {
|
.tag {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill,
|
.pill,
|
||||||
.pill-button {
|
.pill-button {
|
||||||
padding: 8px 12px;
|
padding: 7px 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,97 +291,155 @@ const summaryCards = computed(() => {
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-card {
|
.trader-layout,
|
||||||
|
.content-grid,
|
||||||
|
.stock-table {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.1fr 0.9fr;
|
gap: 12px;
|
||||||
gap: 16px;
|
min-height: 0;
|
||||||
margin-bottom: 16px;
|
}
|
||||||
|
|
||||||
|
.trader-layout {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card,
|
.profile-card,
|
||||||
.summary-box,
|
|
||||||
.card-panel,
|
.card-panel,
|
||||||
.stock-row {
|
.stock-row {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
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 {
|
.profile-card {
|
||||||
padding: 20px;
|
background: linear-gradient(135deg, rgba(212, 163, 92, 0.16), rgba(255, 255, 255, 0.02));
|
||||||
background: linear-gradient(135deg, rgba(212, 163, 92, 0.18), rgba(255, 255, 255, 0.02));
|
}
|
||||||
|
|
||||||
|
.profile-card.compact {
|
||||||
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-title {
|
.profile-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 34px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-desc {
|
.profile-title.compact {
|
||||||
margin: 10px 0 16px;
|
font-size: 15px;
|
||||||
color: #d6ceb9;
|
white-space: nowrap;
|
||||||
line-height: 1.8;
|
}
|
||||||
|
|
||||||
|
.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;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.summary-chip.danger strong {
|
||||||
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 {
|
|
||||||
color: var(--color-red);
|
color: var(--color-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-box.watch .summary-value {
|
.summary-chip.watch strong {
|
||||||
color: var(--color-orange);
|
color: var(--color-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-box.focus .summary-value {
|
.summary-chip.focus strong {
|
||||||
color: var(--color-gold-soft);
|
color: var(--color-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-panel {
|
.stock-panel {
|
||||||
padding: 18px;
|
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);
|
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 {
|
.stock-table {
|
||||||
display: grid;
|
overflow: auto;
|
||||||
gap: 10px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-row {
|
.stock-row {
|
||||||
display: grid;
|
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;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
@ -309,21 +450,35 @@ const summaryCards = computed(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-core strong {
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-core span {
|
.stock-core span {
|
||||||
color: var(--color-muted);
|
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 {
|
.tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 74px;
|
min-width: 66px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,6 +494,12 @@ const summaryCards = computed(() => {
|
|||||||
border-color: rgba(255, 174, 66, 0.18);
|
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 {
|
.tag.blue {
|
||||||
color: #acd8ff;
|
color: #acd8ff;
|
||||||
background: rgba(90, 184, 255, 0.14);
|
background: rgba(90, 184, 255, 0.14);
|
||||||
@ -357,15 +518,27 @@ const summaryCards = computed(() => {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1160px) {
|
@media (max-width: 1280px) {
|
||||||
.header-card {
|
.content-grid,
|
||||||
|
.filter-bar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.stock-row {
|
.stock-row {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
@ -20,6 +20,13 @@ const countsByType = computed(() => {
|
|||||||
return acc
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -41,14 +48,14 @@ const countsByType = computed(() => {
|
|||||||
<article class="filter-box">
|
<article class="filter-box">
|
||||||
<h3 class="section-title">游资筛选</h3>
|
<h3 class="section-title">游资筛选</h3>
|
||||||
<div
|
<div
|
||||||
v-for="trader in traders"
|
v-for="trader in traders"
|
||||||
:key="trader.id"
|
:key="trader.id"
|
||||||
class="check-row"
|
class="check-row"
|
||||||
>
|
>
|
||||||
<span>{{ trader.name }}</span>
|
<span>{{ trader.name }}</span>
|
||||||
<strong>{{ trader.stock_count }}</strong>
|
<strong>{{ countsByTrader[trader.name] ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="filter-box">
|
<article class="filter-box">
|
||||||
<h3 class="section-title">预警类型</h3>
|
<h3 class="section-title">预警类型</h3>
|
||||||
|
|||||||
@ -172,11 +172,19 @@ export function useDashboardData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectTrader(traderId: number) {
|
async function selectTrader(traderId: number) {
|
||||||
|
if (traderDetail.value?.trader.id === traderId) {
|
||||||
|
selectedTraderId.value = traderId
|
||||||
|
return
|
||||||
|
}
|
||||||
selectedTraderId.value = traderId
|
selectedTraderId.value = traderId
|
||||||
traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`)
|
traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectStock(stockCode: string) {
|
async function selectStock(stockCode: string) {
|
||||||
|
if (stockDetail.value?.stock.stock_code === stockCode) {
|
||||||
|
selectedStockCode.value = stockCode
|
||||||
|
return
|
||||||
|
}
|
||||||
selectedStockCode.value = stockCode
|
selectedStockCode.value = stockCode
|
||||||
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
||||||
}
|
}
|
||||||
@ -218,13 +226,21 @@ export function useDashboardData() {
|
|||||||
await loadActions()
|
await loadActions()
|
||||||
|
|
||||||
if (traderResult[0]) {
|
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) {
|
if (preferredStockCode) {
|
||||||
selectedWarningCode.value = preferredStockCode
|
selectedStockCode.value = preferredStockCode
|
||||||
await selectStock(preferredStockCode)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = String(error instanceof Error ? error.message : error)
|
errorMessage.value = String(error instanceof Error ? error.message : error)
|
||||||
|
|||||||
@ -72,8 +72,14 @@ export interface TraderStock {
|
|||||||
last_trade_date: string | null
|
last_trade_date: string | null
|
||||||
buy_action_count: number
|
buy_action_count: number
|
||||||
sell_action_count: number
|
sell_action_count: number
|
||||||
|
total_net_amount_wan?: number | null
|
||||||
has_sell_alert: number
|
has_sell_alert: number
|
||||||
has_slow_exit: 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 {
|
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' {
|
export function priceTone(value: string | null | undefined): 'rise' | 'fall' | 'flat' {
|
||||||
if (!value) return 'flat'
|
const parsed = numberFromText(value)
|
||||||
if (String(value).startsWith('-')) return 'fall'
|
if (parsed === null) return 'flat'
|
||||||
if (String(value).startsWith('+') || Number(value) > 0) return 'rise'
|
if (parsed < 0) return 'fall'
|
||||||
|
if (parsed > 0) return 'rise'
|
||||||
return 'flat'
|
return 'flat'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user