Initial commit

This commit is contained in:
wanghep
2026-03-20 21:47:30 +08:00
commit 2eab960303
83 changed files with 51694 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

79
backend/app/api/routes.py Normal file
View File

@ -0,0 +1,79 @@
from fastapi import APIRouter, Query
from app.api.schemas import (
AShareIndexFlowResponse,
AShareSectorFlowResponse,
HealthResponse,
HistoryResponse,
MetaResponse,
OverviewResponse,
PushRecord,
PushRecordsResponse,
RulesResponse,
SourceDiagnosticsResponse,
)
from app.services.ashare_flow_service import ashare_flow_service
from app.services.alert_service import alert_service
from app.services.monitoring_service import monitoring_service
router = APIRouter()
@router.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok")
@router.get("/meta", response_model=MetaResponse)
def meta() -> MetaResponse:
return monitoring_service.get_meta()
@router.get("/overview", response_model=OverviewResponse)
def overview() -> OverviewResponse:
return monitoring_service.get_overview()
@router.get("/history", response_model=HistoryResponse)
def history() -> HistoryResponse:
return monitoring_service.get_history()
@router.get("/push-records", response_model=PushRecordsResponse)
def push_records() -> PushRecordsResponse:
return monitoring_service.get_push_records()
@router.post("/push-records/test", response_model=PushRecord)
def send_test_push_record() -> PushRecord:
return PushRecord(**alert_service.send_test_alert())
@router.get("/rules", response_model=RulesResponse)
def rules() -> RulesResponse:
return monitoring_service.get_rules()
@router.get("/source-diagnostics", response_model=SourceDiagnosticsResponse)
def source_diagnostics() -> SourceDiagnosticsResponse:
return monitoring_service.get_source_diagnostics()
@router.get("/ashare/index-flows/realtime", response_model=AShareIndexFlowResponse)
def ashare_index_realtime() -> AShareIndexFlowResponse:
return AShareIndexFlowResponse(**ashare_flow_service.get_index_realtime())
@router.get("/ashare/index-flows/daily", response_model=AShareIndexFlowResponse)
def ashare_index_daily(trade_date: str | None = Query(default=None)) -> AShareIndexFlowResponse:
return AShareIndexFlowResponse(**ashare_flow_service.get_index_daily(trade_date))
@router.get("/ashare/sector-flows/realtime", response_model=AShareSectorFlowResponse)
def ashare_sector_realtime() -> AShareSectorFlowResponse:
return AShareSectorFlowResponse(**ashare_flow_service.get_sector_realtime())
@router.get("/ashare/sector-flows/daily", response_model=AShareSectorFlowResponse)
def ashare_sector_daily(trade_date: str | None = Query(default=None)) -> AShareSectorFlowResponse:
return AShareSectorFlowResponse(**ashare_flow_service.get_sector_daily(trade_date))

201
backend/app/api/schemas.py Normal file
View File

@ -0,0 +1,201 @@
from typing import Literal
from pydantic import BaseModel, Field
Precision = Literal["realtime_exact", "close_final", "historical_exact", "unavailable"]
MarketState = Literal["pre_open", "trading_am", "midday_break", "trading_pm", "finalizing", "closed"]
PushStatus = Literal["pending", "sent", "failed", "skipped"]
class HealthResponse(BaseModel):
status: str
class ValueWithStatus(BaseModel):
amount_hkd_billion: float | None = None
precision: Precision
label: str
class OverviewSnapshot(BaseModel):
trade_date: str
snapshot_time: str | None = None
market_state: MarketState
total_net_inflow: ValueWithStatus
cumulative_net_inflow: ValueWithStatus
shanghai_net_inflow: ValueWithStatus
shenzhen_net_inflow: ValueWithStatus
buy_amount: ValueWithStatus
sell_amount: ValueWithStatus
net_buy_amount: ValueWithStatus
one_min_change: ValueWithStatus
five_min_change: ValueWithStatus
threshold_progress: float = Field(ge=0, le=1)
next_threshold_hkd_billion: float
source_name: str
source_url: str | None = None
updated_at: str | None = None
unavailable_reason: str | None = None
class TimelinePoint(BaseModel):
timestamp: str
amount_hkd_billion: float | None = None
precision: Precision
class BenchmarkTimelinePoint(BaseModel):
timestamp: str
value: float | None = None
class BenchmarkTimelineSeries(BaseModel):
key: str
label: str
unit: str
detail_url: str | None = None
points: list[BenchmarkTimelinePoint]
class PushRecord(BaseModel):
id: str
triggered_at: str
push_type: str
rule_code: str
trigger_value_hkd_billion: float | None = None
description: str
email_subject: str
email_summary: str
status: PushStatus
error_message: str | None = None
class PushRecordsResponse(BaseModel):
records: list[PushRecord]
class OverviewResponse(BaseModel):
snapshot: OverviewSnapshot
minute_timeline: list[TimelinePoint]
benchmark_series: list[BenchmarkTimelineSeries]
recent_push_records: list[PushRecord]
class StatPoint(BaseModel):
period: str
amount_hkd_billion: float
class RecentTradeDay(BaseModel):
trade_date: str
total_net_inflow_hkd_billion: float
precision: Precision
class HistorySummary(BaseModel):
cumulative_net_inflow_hkd_billion: float
trading_day_count: int
max_single_day_inflow_hkd_billion: float
max_single_day_outflow_hkd_billion: float
longest_inflow_streak: int
longest_outflow_streak: int
class HistoryResponse(BaseModel):
start_date: str
daily: list[StatPoint]
weekly: list[StatPoint]
monthly: list[StatPoint]
cumulative: list[StatPoint]
benchmark_history: dict[str, list[StatPoint]]
recent_trade_days: list[RecentTradeDay]
summary: HistorySummary
class RuleItem(BaseModel):
key: str
label: str
value: str
description: str
class RulesResponse(BaseModel):
items: list[RuleItem]
class SourceDiagnosticsResponse(BaseModel):
source_name: str
realtime_available: bool
historical_available: bool
last_success_at: str | None = None
last_failure_at: str | None = None
last_error_reason: str | None = None
last_success_url: str | None = None
last_persisted_at: str | None = None
class MetaResponse(BaseModel):
product_name: str
version: str
timezone: str
market_state: MarketState
current_trade_date: str
source_name: str
source_strategy: str
note: str
class AShareFlowRecord(BaseModel):
trade_date: str
code: str
name: str
detail_url: str | None = None
latest_price: float | None = None
change_amount: float | None = None
change_percent: float | None = None
main_net_inflow: float | None = None
main_net_inflow_ratio: float | None = None
super_large_net_inflow: float | None = None
super_large_net_inflow_ratio: float | None = None
large_net_inflow: float | None = None
large_net_inflow_ratio: float | None = None
medium_net_inflow: float | None = None
medium_net_inflow_ratio: float | None = None
small_net_inflow: float | None = None
small_net_inflow_ratio: float | None = None
rolling_net_inflow_5d: float | None = None
rolling_net_inflow_10d: float | None = None
rolling_net_inflow_30d: float | None = None
rolling_net_inflow_60d: float | None = None
rolling_net_inflow_90d: float | None = None
updated_at: str | None = None
source_name: str
source_url: str | None = None
precision: Precision
snapshot_time: str | None = None
sector_type: str | None = None
sector_type_label: str | None = None
class AShareSectorGroup(BaseModel):
label: str
records: list[AShareFlowRecord]
class AShareIndexFlowResponse(BaseModel):
trade_date: str
updated_at: str | None = None
source_name: str
source_url: str | None = None
precision: Precision
records: list[AShareFlowRecord]
class AShareSectorFlowResponse(BaseModel):
trade_date: str
updated_at: str | None = None
source_name: str
source_url: str | None = None
precision: Precision
sector_types: dict[str, AShareSectorGroup]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,259 @@
from __future__ import annotations
import json
import subprocess
import time
from typing import Any
from urllib.parse import urlencode
from urllib.request import Request, urlopen
class EastmoneyClient:
HISTORY_ENDPOINT = "https://datacenter-web.eastmoney.com/api/data/v1/get"
KAMT_ENDPOINT = "https://push2.eastmoney.com/api/qt/kamt/get"
KAMT_BSMIN_ENDPOINT = "https://push2.eastmoney.com/api/qt/kamtbs.rtmin/get"
CLIST_ENDPOINT = "https://push2.eastmoney.com/api/qt/clist/get"
QUOTE_ENDPOINT = "https://push2.eastmoney.com/api/qt/stock/get"
STOCK_TRENDS_ENDPOINT = "https://push2his.eastmoney.com/api/qt/stock/trends2/get"
STOCK_KLINE_ENDPOINT = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
FFLOW_DAYKLINE_ENDPOINT = "https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get"
FFLOW_MINUTE_KLINE_ENDPOINT = "https://push2.eastmoney.com/api/qt/stock/fflow/kline/get"
def __init__(self) -> None:
self.default_headers = {
"User-Agent": "Mozilla/5.0",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://data.eastmoney.com/hsgt/hsgtV2.html",
}
@staticmethod
def _parse_json_payload(content: str) -> dict[str, Any]:
payload = content.strip()
if payload.startswith("{"):
return json.loads(payload)
left = payload.find("(")
right = payload.rfind(")")
if left != -1 and right != -1 and right > left:
return json.loads(payload[left + 1 : right])
raise ValueError("unexpected eastmoney payload")
def _get_json(
self,
url: str,
params: dict[str, Any],
*,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
full_url = f"{url}?{urlencode(params)}"
request_headers = {**self.default_headers, **(headers or {})}
last_error: Exception | None = None
for attempt in range(5):
request = Request(full_url, headers=request_headers)
try:
with urlopen(request, timeout=20) as response:
content = response.read().decode("utf-8", "ignore")
return self._parse_json_payload(content)
except Exception as exc:
last_error = exc
if attempt < 4:
time.sleep(attempt + 1)
if last_error is not None:
raise last_error
raise RuntimeError("东方财富接口请求失败")
def fetch_realtime_overview(self) -> dict[str, Any]:
return self._get_json(
self.KAMT_ENDPOINT,
{
"fields1": "f1,f2,f3,f4",
"fields2": "f51,f52,f53,f54,f56,f60,f61,f62,f63,f65,f66",
"ut": "fa5fd1943c7b386f172d6893dbfba10b",
},
)
def fetch_intraday_timeline(self) -> dict[str, Any]:
return self._get_json(
self.KAMT_BSMIN_ENDPOINT,
{
"fields1": "f1,f2,f3,f4",
"fields2": "f51,f54,f52,f58,f53,f62,f56,f57,f60,f61",
"ut": "b2884a393a59ad64002292a3e90d46a5",
},
)
def fetch_history(self, start_date: str, page_size: int = 500) -> list[dict[str, Any]]:
page_number = 1
pages = 1
rows: list[dict[str, Any]] = []
while page_number <= pages:
response = self._get_json(
self.HISTORY_ENDPOINT,
{
"reportName": "RPT_MUTUAL_DEAL_HISTORY",
"columns": "ALL",
"pageNumber": page_number,
"pageSize": page_size,
"sortColumns": "TRADE_DATE",
"sortTypes": "-1",
"source": "WEB",
"client": "WEB",
"filter": f'(MUTUAL_TYPE in ("002","004"))(TRADE_DATE>=\'{start_date}\')',
},
)
result = response.get("result") or {}
pages = result.get("pages") or 0
rows.extend(result.get("data") or [])
page_number += 1
return rows
def fetch_sector_realtime_page(
self,
*,
sector_type_fs: str,
page_number: int = 1,
page_size: int = 100,
) -> dict[str, Any]:
timestamp = int(time.time() * 1000)
params = {
"cb": f"jQuery11230{timestamp}",
"pn": page_number,
"pz": page_size,
"po": 1,
"np": 1,
"fltt": 2,
"invt": 2,
"fid": "f62",
"ut": "b2884a393a59ad64002292a3e90d46a5",
"fs": sector_type_fs,
"fields": "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f124,f204,f205,f206",
"_": str(timestamp),
}
headers = {"Referer": "https://data.eastmoney.com/bkzj/hy.html"}
try:
return self._get_json(self.CLIST_ENDPOINT, params, headers=headers)
except Exception:
return self._get_json_via_curl(self.CLIST_ENDPOINT, params, headers=headers)
def _get_json_via_curl(
self,
url: str,
params: dict[str, Any],
*,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
request_headers = {**self.default_headers, **(headers or {})}
full_url = f"{url}?{urlencode(params)}"
command = [
"curl.exe",
"--silent",
"--show-error",
"--connect-timeout",
"20",
"--max-time",
"30",
full_url,
]
for key, value in request_headers.items():
command.extend(["-H", f"{key}: {value}"])
last_error: Exception | None = None
for attempt in range(3):
try:
completed = subprocess.run(command, capture_output=True, text=True, check=True)
return self._parse_json_payload(completed.stdout)
except Exception as exc:
last_error = exc
if attempt < 2:
time.sleep(attempt + 1)
if last_error is not None:
raise last_error
raise RuntimeError("curl fallback failed")
def fetch_all_sector_realtime(self, *, sector_type_fs: str, page_size: int = 100) -> list[dict[str, Any]]:
page_number = 1
pages = 1
rows: list[dict[str, Any]] = []
while page_number <= pages:
response = self.fetch_sector_realtime_page(
sector_type_fs=sector_type_fs,
page_number=page_number,
page_size=page_size,
)
data = response.get("data") or {}
page_rows = data.get("diff") or []
total = int(data.get("total") or 0)
pages = max((total + page_size - 1) // page_size, 1)
rows.extend(page_rows)
if not page_rows:
break
time.sleep(0.15)
page_number += 1
return rows
def fetch_quote(self, secid: str) -> dict[str, Any]:
return self._get_json(
self.QUOTE_ENDPOINT,
{
"secid": secid,
"fields": "f57,f58,f43,f169,f170,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87",
},
)
def fetch_stock_trends(self, secid: str, *, ndays: int = 1) -> dict[str, Any]:
return self._get_json(
self.STOCK_TRENDS_ENDPOINT,
{
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": 0,
"ndays": ndays,
},
)
def fetch_stock_kline(self, secid: str, *, limit: int = 120, klt: int = 101) -> dict[str, Any]:
return self._get_json(
self.STOCK_KLINE_ENDPOINT,
{
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
"klt": klt,
"fqt": 0,
"lmt": limit,
"end": "20500101",
},
)
def fetch_fund_flow_daykline(self, secid: str, *, limit: int = 600) -> dict[str, Any]:
return self._get_json(
self.FFLOW_DAYKLINE_ENDPOINT,
{
"lmt": limit,
"klt": 101,
"secid": secid,
"fields1": "f1,f2,f3,f7",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63",
},
)
def fetch_fund_flow_minute_kline(self, secid: str, *, limit: int = 1) -> dict[str, Any]:
return self._get_json(
self.FFLOW_MINUTE_KLINE_ENDPOINT,
{
"lmt": limit,
"klt": 1,
"secid": secid,
"fields1": "f1,f2,f3,f7",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63",
},
)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,126 @@
import json
from pathlib import Path
from app.core.config import (
ALERT_TRIGGERS_DIR,
DAILY_STATS_DIR,
MINUTE_SNAPSHOTS_DIR,
MONTHLY_STATS_DIR,
PUSH_RECORDS_DIR,
RAW_PAYLOADS_DIR,
SOURCE_DIAGNOSTICS_FILE,
SYSTEM_CONFIG_FILE,
WEEKLY_STATS_DIR,
)
DEFAULT_SYSTEM_CONFIG = {
"product_name": "南向资金监控平台",
"timezone": "Asia/Shanghai",
"source_name": "东方财富",
"source_strategy": "已接入东方财富历史与实时公开接口;历史来自 datacenter-web实时来自 push2。",
"realtime_collection_interval_seconds": 60,
"history_backfill_start_date": "2026-01-01",
"threshold_step_hkd_billion": 50,
"five_minute_flow_alert_hkd_billion": 15,
"five_minute_window_minutes": 5,
"five_minute_cooldown_minutes": 5,
"email_enabled": False,
"sender_email": "alerts@example.com",
"smtp_username": "alerts@example.com",
"smtp_password": "",
"smtp_host": "smtp.example.com",
"smtp_port": 465,
"recipients": ["ops@example.com"],
"storage_backend": "mysql",
"mysql_enabled": False,
"mysql_host": "127.0.0.1",
"mysql_port": 3306,
"mysql_database": "southbound_monitor",
"mysql_username": "root",
"mysql_password": "",
"mysql_charset": "utf8mb4",
}
DEFAULT_SOURCE_DIAGNOSTICS = {
"source_name": "东方财富",
"realtime_available": False,
"historical_available": False,
"last_success_at": None,
"last_failure_at": None,
"last_error_reason": "尚未执行东方财富真实同步任务。",
"last_success_url": None,
"last_persisted_at": None,
}
DEFAULT_MINUTE_SNAPSHOT = {
"trade_date": "2026-03-20",
"snapshot_time": None,
"market_state": "closed",
"total_net_inflow": None,
"cumulative_net_inflow": None,
"shanghai_net_inflow": None,
"shenzhen_net_inflow": None,
"buy_amount": None,
"sell_amount": None,
"net_buy_amount": None,
"one_min_change": None,
"five_min_change": None,
"precision": "unavailable",
"source_name": "东方财富",
"source_url": None,
"updated_at": None,
"unavailable_reason": "尚未获取到今天的实时快照数据。",
"threshold_progress": 0.0,
"next_threshold_hkd_billion": 50,
"minute_timeline": [],
}
DEFAULT_DAILY_STATS = {
"start_date": "2026-01-01",
"daily": [],
"weekly": [],
"monthly": [],
"cumulative": [],
"recent_trade_days": [],
"summary": {
"cumulative_net_inflow_hkd_billion": 0,
"trading_day_count": 0,
"max_single_day_inflow_hkd_billion": 0,
"max_single_day_outflow_hkd_billion": 0,
"longest_inflow_streak": 0,
"longest_outflow_streak": 0,
},
}
DEFAULT_PUSH_RECORDS = {"records": []}
def _ensure_json(path: Path, payload: dict) -> None:
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def bootstrap_data() -> None:
for directory in [
MINUTE_SNAPSHOTS_DIR,
DAILY_STATS_DIR,
WEEKLY_STATS_DIR,
MONTHLY_STATS_DIR,
PUSH_RECORDS_DIR,
ALERT_TRIGGERS_DIR,
RAW_PAYLOADS_DIR,
]:
directory.mkdir(parents=True, exist_ok=True)
_ensure_json(SYSTEM_CONFIG_FILE, DEFAULT_SYSTEM_CONFIG)
_ensure_json(SOURCE_DIAGNOSTICS_FILE, DEFAULT_SOURCE_DIAGNOSTICS)
_ensure_json(MINUTE_SNAPSHOTS_DIR / "2026-03-20.json", DEFAULT_MINUTE_SNAPSHOT)
_ensure_json(DAILY_STATS_DIR / "summary.json", DEFAULT_DAILY_STATS)
_ensure_json(PUSH_RECORDS_DIR / "records.json", DEFAULT_PUSH_RECORDS)

View File

@ -0,0 +1,16 @@
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parents[2]
DATA_DIR = BASE_DIR / "data"
MINUTE_SNAPSHOTS_DIR = DATA_DIR / "minute_snapshots"
DAILY_STATS_DIR = DATA_DIR / "daily_stats"
WEEKLY_STATS_DIR = DATA_DIR / "weekly_stats"
MONTHLY_STATS_DIR = DATA_DIR / "monthly_stats"
PUSH_RECORDS_DIR = DATA_DIR / "push_records"
ALERT_TRIGGERS_DIR = DATA_DIR / "alert_triggers"
RAW_PAYLOADS_DIR = DATA_DIR / "raw_payloads"
SYSTEM_CONFIG_FILE = DATA_DIR / "system_config.json"
SOURCE_DIAGNOSTICS_FILE = DATA_DIR / "source_diagnostics.json"
HISTORY_START_DATE = "2026-01-01"

38
backend/app/main.py Normal file
View File

@ -0,0 +1,38 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.core.bootstrap import bootstrap_data
from app.services.sync_scheduler import sync_scheduler
def create_app() -> FastAPI:
app = FastAPI(
title="Southbound Capital Monitor",
version="0.1.0",
description="南向资金监控平台 API",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
bootstrap_data()
@app.on_event("startup")
def startup_sync_scheduler() -> None:
sync_scheduler.start()
@app.on_event("shutdown")
def shutdown_sync_scheduler() -> None:
sync_scheduler.stop()
app.include_router(router, prefix="/api")
return app
app = create_app()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,22 @@
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any
class JsonRepository:
def __init__(self, path: Path) -> None:
self.path = path
def read(self, default: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.path.exists():
return default or {}
with self.path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def write(self, payload: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile("w", delete=False, dir=self.path.parent, encoding="utf-8") as temp:
json.dump(payload, temp, ensure_ascii=False, indent=2)
temp_path = Path(temp.name)
temp_path.replace(self.path)

View File

@ -0,0 +1,196 @@
from pathlib import Path
from typing import Any
from app.core.config import (
DAILY_STATS_DIR,
MINUTE_SNAPSHOTS_DIR,
PUSH_RECORDS_DIR,
RAW_PAYLOADS_DIR,
SOURCE_DIAGNOSTICS_FILE,
SYSTEM_CONFIG_FILE,
)
from app.repositories.json_repository import JsonRepository
from app.repositories.mysql_repository import MySQLRepository
class MonitoringRepository:
def __init__(self) -> None:
self.system_config_repo = JsonRepository(SYSTEM_CONFIG_FILE)
self.source_diagnostics_repo = JsonRepository(SOURCE_DIAGNOSTICS_FILE)
self.history_repo = JsonRepository(DAILY_STATS_DIR / "summary.json")
self.push_records_repo = JsonRepository(PUSH_RECORDS_DIR / "records.json")
self._mysql_repository: MySQLRepository | None = None
def _get_bootstrap_config(self) -> dict[str, Any]:
return self.system_config_repo.read({})
def _should_use_mysql(self) -> bool:
config = self._get_bootstrap_config()
return bool(
config.get("storage_backend") == "mysql"
and config.get("mysql_enabled")
and config.get("mysql_host")
and config.get("mysql_database")
and config.get("mysql_username")
)
def _mysql(self) -> MySQLRepository | None:
if not self._should_use_mysql():
return None
if self._mysql_repository is None:
self._mysql_repository = MySQLRepository(self._get_bootstrap_config())
return self._mysql_repository
def get_system_config(self) -> dict:
mysql = self._mysql()
if mysql is None:
return self.system_config_repo.read()
payload = mysql.read_document("system_config", "default", self.system_config_repo.read({}))
if payload:
return payload
fallback = self.system_config_repo.read({})
if fallback:
mysql.write_document("system_config", "default", fallback)
return fallback
def save_system_config(self, payload: dict) -> None:
self.system_config_repo.write(payload)
mysql = self._mysql()
if mysql is not None:
mysql.write_document("system_config", "default", payload)
def get_source_diagnostics(self) -> dict:
mysql = self._mysql()
if mysql is None:
return self.source_diagnostics_repo.read()
payload = mysql.read_document("source_diagnostics", "default", self.source_diagnostics_repo.read({}))
if payload:
return payload
fallback = self.source_diagnostics_repo.read({})
if fallback:
mysql.write_document("source_diagnostics", "default", fallback)
return fallback
def save_source_diagnostics(self, payload: dict) -> None:
self.source_diagnostics_repo.write(payload)
mysql = self._mysql()
if mysql is not None:
mysql.write_document("source_diagnostics", "default", payload)
def get_snapshot_by_trade_date(self, trade_date: str) -> dict:
mysql = self._mysql()
if mysql is not None:
payload = mysql.read_document("minute_snapshot", trade_date, {})
if payload:
return payload
path = MINUTE_SNAPSHOTS_DIR / f"{trade_date}.json"
return JsonRepository(path).read({})
def get_latest_snapshot(self) -> dict:
mysql = self._mysql()
if mysql is not None:
rows = mysql.list_documents("minute_snapshot", limit=1)
if rows:
return rows[0]
files = sorted(MINUTE_SNAPSHOTS_DIR.glob("*.json"))
if not files:
return {}
return JsonRepository(files[-1]).read()
def save_snapshot(self, trade_date: str, payload: dict) -> None:
JsonRepository(MINUTE_SNAPSHOTS_DIR / f"{trade_date}.json").write(payload)
mysql = self._mysql()
if mysql is not None:
mysql.write_document("minute_snapshot", trade_date, payload, sort_value=trade_date)
def get_history(self) -> dict:
mysql = self._mysql()
if mysql is None:
return self.history_repo.read()
payload = mysql.read_document("history_summary", "default", self.history_repo.read({}))
if payload:
return payload
fallback = self.history_repo.read({})
if fallback:
mysql.write_document("history_summary", "default", fallback)
return fallback
def save_history(self, payload: dict) -> None:
self.history_repo.write(payload)
mysql = self._mysql()
if mysql is not None:
mysql.write_document("history_summary", "default", payload)
def get_push_records(self) -> dict:
mysql = self._mysql()
if mysql is not None:
records = mysql.list_documents("push_record")
if records:
return {"records": records}
return self.push_records_repo.read({"records": []})
def save_push_records(self, payload: dict) -> None:
self.push_records_repo.write(payload)
mysql = self._mysql()
if mysql is not None:
for record in payload.get("records", []):
mysql.write_document(
"push_record",
record["id"],
record,
sort_value=record.get("triggered_at"),
)
def append_push_record(self, record: dict) -> dict:
payload = self.get_push_records()
records = payload.get("records", [])
records.insert(0, record)
payload["records"] = records
self.save_push_records(payload)
return record
def get_alert_state(self, trade_date: str) -> dict:
mysql = self._mysql()
if mysql is not None:
payload = mysql.read_document("alert_state", trade_date, {})
if payload:
return payload
return {}
def save_alert_state(self, trade_date: str, payload: dict) -> None:
mysql = self._mysql()
if mysql is not None:
mysql.write_document("alert_state", trade_date, payload, sort_value=trade_date)
def save_raw_payload(self, name: str, payload: dict) -> Path:
path = RAW_PAYLOADS_DIR / f"{name}.json"
JsonRepository(path).write(payload)
mysql = self._mysql()
if mysql is not None:
mysql.write_document("raw_payload", name, payload, sort_value=name)
return path
def get_document(self, category: str, doc_key: str, default: dict | None = None) -> dict:
mysql = self._mysql()
if mysql is not None:
payload = mysql.read_document(category, doc_key, default or {})
if payload:
return payload
return default or {}
def save_document(self, category: str, doc_key: str, payload: dict, *, sort_value: str | None = None) -> None:
mysql = self._mysql()
if mysql is not None:
mysql.write_document(category, doc_key, payload, sort_value=sort_value)
def list_documents(
self,
category: str,
*,
limit: int | None = None,
descending: bool = True,
) -> list[dict]:
mysql = self._mysql()
if mysql is None:
return []
return mysql.list_documents(category, limit=limit, descending=descending)

View File

@ -0,0 +1,95 @@
import json
from datetime import datetime
from typing import Any
import pymysql
class MySQLRepository:
def __init__(self, config: dict[str, Any]) -> None:
self.config = config
self._ensure_schema()
def _connect(self):
return pymysql.connect(
host=self.config["mysql_host"],
port=int(self.config.get("mysql_port", 3306)),
user=self.config["mysql_username"],
password=self.config["mysql_password"],
database=self.config["mysql_database"],
charset=self.config.get("mysql_charset", "utf8mb4"),
autocommit=True,
cursorclass=pymysql.cursors.DictCursor,
)
def _ensure_schema(self) -> None:
statements = [
"""
CREATE TABLE IF NOT EXISTS app_documents (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
category VARCHAR(64) NOT NULL,
doc_key VARCHAR(128) NOT NULL,
sort_value VARCHAR(64) DEFAULT NULL,
payload LONGTEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uniq_category_key (category, doc_key),
KEY idx_category_sort (category, sort_value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
]
with self._connect() as connection:
with connection.cursor() as cursor:
for statement in statements:
cursor.execute(statement)
def read_document(self, category: str, doc_key: str, default: dict[str, Any] | None = None) -> dict[str, Any]:
sql = "SELECT payload FROM app_documents WHERE category=%s AND doc_key=%s LIMIT 1"
with self._connect() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, (category, doc_key))
row = cursor.fetchone()
if not row:
return default or {}
return json.loads(row["payload"])
def write_document(
self,
category: str,
doc_key: str,
payload: dict[str, Any],
*,
sort_value: str | None = None,
) -> None:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = """
INSERT INTO app_documents (category, doc_key, sort_value, payload, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
sort_value=VALUES(sort_value),
payload=VALUES(payload),
updated_at=VALUES(updated_at)
"""
serialized = json.dumps(payload, ensure_ascii=False)
with self._connect() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, (category, doc_key, sort_value, serialized, now, now))
def list_documents(
self,
category: str,
*,
limit: int | None = None,
descending: bool = True,
) -> list[dict[str, Any]]:
direction = "DESC" if descending else "ASC"
sql = f"SELECT payload FROM app_documents WHERE category=%s ORDER BY sort_value {direction}, updated_at {direction}"
params: list[Any] = [category]
if limit is not None:
sql += " LIMIT %s"
params.append(limit)
with self._connect() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [json.loads(row["payload"]) for row in rows]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from app.repositories.monitoring_repository import MonitoringRepository
from app.services.alert_service import AlertService
class AlertEngine:
def __init__(self) -> None:
self.repository = MonitoringRepository()
self.tz = ZoneInfo("Asia/Shanghai")
self.alert_service = AlertService()
def evaluate(self, snapshot: dict) -> list[dict]:
trade_date = snapshot["trade_date"]
state = self._load_state(trade_date)
config = self.repository.get_system_config()
results: list[dict] = []
results.extend(self._evaluate_threshold_break(snapshot, config, state))
results.extend(self._evaluate_five_minute(snapshot, config, state))
self._save_state(trade_date, state)
return results
def _load_state(self, trade_date: str) -> dict[str, Any]:
payload = self.repository.get_alert_state(trade_date)
if not payload:
return {
"trade_date": trade_date,
"thresholds_triggered": [],
"last_five_minute_alert_at": None,
}
payload.setdefault("trade_date", trade_date)
payload.setdefault("thresholds_triggered", [])
payload.setdefault("last_five_minute_alert_at", None)
return payload
def _save_state(self, trade_date: str, state: dict[str, Any]) -> None:
payload = {
"trade_date": trade_date,
"thresholds_triggered": sorted(set(state.get("thresholds_triggered", []))),
"last_five_minute_alert_at": state.get("last_five_minute_alert_at"),
}
self.repository.save_alert_state(trade_date, payload)
def _evaluate_threshold_break(
self, snapshot: dict, config: dict, state: dict[str, Any]
) -> list[dict]:
total = snapshot.get("total_net_inflow")
if total is None:
return []
threshold_step = float(config.get("threshold_step_hkd_billion", 40))
if threshold_step <= 0 or abs(total) < threshold_step:
return []
results: list[dict] = []
triggered_values = {float(value) for value in state.get("thresholds_triggered", [])}
direction = 1 if total > 0 else -1
current_step = int(abs(total) // threshold_step)
for step in range(1, current_step + 1):
threshold_value = direction * step * threshold_step
if threshold_value in triggered_values:
continue
flow_label = "净流入" if threshold_value > 0 else "净流出"
threshold_abs = abs(threshold_value)
record = self.alert_service.send_snapshot_alert(
snapshot=snapshot,
rule_code="threshold_break",
subject=f"[南向资金监控] {snapshot['trade_date']} 南向{flow_label}突破 {threshold_abs:.0f} 亿港元",
summary=f"当前累计{flow_label} {AlertService._format_amount(abs(total))} 亿港元,突破 {threshold_abs:.0f} 亿港元档位。",
description=f"累计{flow_label}突破 {threshold_abs:.0f} 亿港元。",
trigger_value=total,
body_note=f"当前南向资金累计{'净流入' if total > 0 else '净流出'} {AlertService._format_amount(abs(total))} 亿港元,已突破 {threshold_abs:.0f} 亿港元。",
)
results.append(record)
triggered_values.add(threshold_value)
state["thresholds_triggered"] = sorted(triggered_values)
return results
def _evaluate_five_minute(
self, snapshot: dict, config: dict, state: dict[str, Any]
) -> list[dict]:
change = snapshot.get("five_min_change")
if change is None:
return []
threshold = float(config.get("five_minute_flow_alert_hkd_billion", 15))
if threshold <= 0 or abs(change) < threshold:
return []
now = datetime.now(self.tz)
last_trigger_at = state.get("last_five_minute_alert_at")
cooldown_minutes = int(config.get("five_minute_cooldown_minutes", 5))
if last_trigger_at:
last_at = datetime.fromisoformat(last_trigger_at)
if now - last_at < timedelta(minutes=cooldown_minutes):
return []
direction = "流入" if change > 0 else "流出"
record = self.alert_service.send_snapshot_alert(
snapshot=snapshot,
rule_code="five_minute_flow",
subject=f"[南向资金监控] {snapshot['trade_date']} 5分钟快速{direction} {abs(change):.4f} 亿港元",
summary=f"5分钟净变化 {abs(change):.4f} 亿港元,超过阈值 {threshold:.4f} 亿港元。",
description=f"5分钟净变化{direction} {change:.4f} 亿港元。",
trigger_value=change,
body_note=f"5分钟净变化{direction} {change:.4f} 亿港元,阈值为 {threshold:.4f} 亿港元。",
)
state["last_five_minute_alert_at"] = now.isoformat(timespec="seconds")
return [record]
alert_engine = AlertEngine()

View File

@ -0,0 +1,195 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from zoneinfo import ZoneInfo
from app.repositories.monitoring_repository import MonitoringRepository
from app.services.email_notification_service import email_notification_service
class AlertService:
def __init__(self) -> None:
self.repository = MonitoringRepository()
self.tz = ZoneInfo("Asia/Shanghai")
@staticmethod
def _format_amount(value: float | None) -> str:
if value is None:
return "-"
return f"{value:.4f}"
@staticmethod
def _format_signed_amount(value: float | None) -> str:
if value is None:
return "-"
return f"{value:+.4f}"
@staticmethod
def _format_points(value: float | None) -> str:
if value is None:
return "-"
return f"{value:.2f}"
@staticmethod
def _build_benchmark_lookup(snapshot: dict) -> dict[str, float | None]:
lookup: dict[str, float | None] = {}
for item in snapshot.get("benchmark_series", []) or []:
points = item.get("points", []) or []
lookup[item.get("label", "")] = points[-1].get("value") if points else None
return lookup
def _build_enhanced_body(
self,
*,
snapshot: dict,
title: str,
summary: str,
note: str | None = None,
operation_advice: str | None = None,
market_context: list[str] | None = None,
) -> str:
benchmarks = self._build_benchmark_lookup(snapshot)
lines = [
title,
"",
f"交易日期: {snapshot.get('trade_date')}",
f"快照时间: {snapshot.get('snapshot_time') or snapshot.get('updated_at') or '-'}",
f"南向总净流入: {self._format_signed_amount(snapshot.get('total_net_inflow'))} 亿港元",
f"1分钟净变化: {self._format_signed_amount(snapshot.get('one_min_change'))} 亿港元",
f"5分钟净变化: {self._format_signed_amount(snapshot.get('five_min_change'))} 亿港元",
f"恒生指数: {self._format_points(benchmarks.get('恒生指数'))}",
f"恒生科技指数: {self._format_points(benchmarks.get('恒生科技指数'))}",
"",
"摘要",
summary,
]
if market_context:
lines.extend(["", "市场要点"])
lines.extend([f"{index}. {item}" for index, item in enumerate(market_context, start=1)])
if operation_advice:
lines.extend(["", "操作建议", operation_advice])
if note:
lines.extend(["", "备注", note])
return "\n".join(lines)
def _send_email(self, subject: str, body: str) -> None:
config = self.repository.get_system_config()
email_notification_service.send(
smtp_host=config.get("smtp_host", ""),
smtp_port=int(config.get("smtp_port", 465)),
smtp_username=config.get("smtp_username", ""),
smtp_password=config.get("smtp_password", ""),
sender_email=config.get("sender_email", ""),
recipients=config.get("recipients", []),
subject=subject,
text_body=body,
)
def _build_record(
self,
*,
snapshot: dict,
rule_code: str,
subject: str,
summary: str,
description: str,
trigger_value: float | None,
) -> dict:
return {
"id": f"push-{uuid4().hex[:12]}",
"triggered_at": datetime.now(self.tz).isoformat(timespec="seconds"),
"push_type": "email",
"rule_code": rule_code,
"trigger_value_hkd_billion": trigger_value,
"description": description,
"email_subject": subject,
"email_summary": summary,
"status": "pending",
"error_message": None,
}
def send_snapshot_alert(
self,
*,
snapshot: dict,
rule_code: str,
subject: str,
summary: str,
description: str,
trigger_value: float | None,
body_note: str | None = None,
body_text: str | None = None,
operation_advice: str | None = None,
market_context: list[str] | None = None,
) -> dict:
body = (
body_text
if body_text is not None
else self._build_enhanced_body(
snapshot=snapshot,
title="南向资金监控自动告警",
summary=summary,
note=body_note,
operation_advice=operation_advice,
market_context=market_context,
)
)
record = self._build_record(
snapshot=snapshot,
rule_code=rule_code,
subject=subject,
summary=summary,
description=description,
trigger_value=trigger_value,
)
try:
self._send_email(subject=subject, body=body)
record["status"] = "sent"
except Exception as exc:
record["status"] = "failed"
record["error_message"] = str(exc)
self.repository.append_push_record(record)
return record
def send_test_alert(self) -> dict:
snapshot = self.repository.get_latest_snapshot()
history = self.repository.get_history()
if not snapshot:
raise ValueError("未找到可用于测试的快照数据")
if snapshot.get("total_net_inflow") is None:
raise ValueError("快照缺少 total_net_inflow无法发送测试告警")
total_net_inflow = snapshot.get("total_net_inflow")
five_min_change = snapshot.get("five_min_change")
cumulative = history.get("summary", {}).get("cumulative_net_inflow_hkd_billion")
summary = (
f"{snapshot.get('trade_date')} 南向资金当前净流入 {self._format_signed_amount(total_net_inflow)} 亿港元,"
f"5分钟净变化 {self._format_signed_amount(five_min_change)} 亿港元,"
f"统计区间累计 {self._format_amount(cumulative)} 亿港元。"
)
body = self._build_enhanced_body(
snapshot=snapshot,
title="南向资金监控提醒",
summary=summary,
market_context=[
"南向资金尾盘仍以净流出为主。",
"恒生科技指数仍处在弱修复阶段。",
],
operation_advice="南向资金仍偏弱,短线先控制仓位,等待南向重新转正后再考虑分批回补恒生科技方向。",
)
return self.send_snapshot_alert(
snapshot=snapshot,
rule_code="manual_test_alert",
subject=f"[南向资金监控] {snapshot.get('trade_date')} 港股操作建议",
summary=summary,
description="用于发送简版正式样式邮件。",
trigger_value=total_net_inflow,
body_text=body,
)
alert_service = AlertService()

View File

@ -0,0 +1,670 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from time import sleep
from zoneinfo import ZoneInfo
from app.clients.eastmoney_client import EastmoneyClient
from app.core.config import HISTORY_START_DATE
from app.repositories.monitoring_repository import MonitoringRepository
SECTOR_TYPE_CONFIG = {
"industry": {
"label": "行业板块",
"fs": "m:90+t:2",
},
"concept": {
"label": "概念板块",
"fs": "m:90+t:3",
},
"region": {
"label": "地域板块",
"fs": "m:90+t:1",
},
}
TRACKED_INDICES = [
{"code": "000001", "name": "上证指数", "secid": "1.000001"},
{"code": "399001", "name": "深证成指", "secid": "0.399001"},
{"code": "399006", "name": "创业板指", "secid": "0.399006"},
{"code": "000300", "name": "沪深300", "secid": "1.000300"},
{"code": "000905", "name": "中证500", "secid": "1.000905"},
{"code": "000852", "name": "中证1000", "secid": "1.000852"},
{"code": "932000", "name": "中证2000", "secid": "2.932000"},
{"code": "000688", "name": "科创50", "secid": "1.000688"},
]
ROLLING_WINDOWS = [5, 10, 30, 60, 90]
class AShareFlowService:
def __init__(self) -> None:
self.client = EastmoneyClient()
self.repository = MonitoringRepository()
self.tz = ZoneInfo("Asia/Shanghai")
@staticmethod
def _safe_float(value: str | float | int | None) -> float | None:
if value in (None, "", "-"):
return None
return float(value)
@staticmethod
def _safe_int(value: str | float | int | None) -> int | None:
if value in (None, "", "-"):
return None
return int(float(value))
@staticmethod
def _normalize_quote_price(value: int | float | None) -> float | None:
if value is None:
return None
return round(float(value) / 100, 2)
@staticmethod
def _normalize_quote_change_amount(value: int | float | None) -> float | None:
if value is None:
return None
return round(float(value) / 100, 2)
@staticmethod
def _normalize_quote_change_percent(value: int | float | None) -> float | None:
if value is None:
return None
return round(float(value) / 100, 2)
@staticmethod
def _normalize_flow_amount(value: str | float | int | None) -> float | None:
parsed = AShareFlowService._safe_float(value)
if parsed is None:
return None
return round(parsed / 100000000, 4)
@staticmethod
def _build_index_detail_url(code: str) -> str:
return f"https://quote.eastmoney.com/zs{code}.html"
@staticmethod
def _build_sector_detail_url(code: str) -> str:
return f"https://quote.eastmoney.com/bk/90.{code}.html"
def _normalize_stored_flow_amount(self, value: str | float | int | None) -> float:
parsed = self._safe_float(value) or 0.0
if abs(parsed) >= 100000:
return round(parsed / 100000000, 4)
return round(parsed, 4)
def _now(self) -> datetime:
return datetime.now(self.tz)
def _today(self) -> str:
return self._now().date().isoformat()
@staticmethod
def _rolling_field(window: int) -> str:
return f"rolling_net_inflow_{window}d"
def _build_history_value_map(self, category: str, *, current_trade_date: str, is_sector: bool) -> dict[str, list[float]]:
payloads = [
item
for item in self.repository.list_documents(category, limit=max(ROLLING_WINDOWS) * 2)
if item.get("trade_date") and item.get("trade_date") != current_trade_date
]
history_map: dict[str, list[float]] = defaultdict(list)
for payload in payloads:
if is_sector:
sector_groups = payload.get("sector_types", {})
for sector_type, group in sector_groups.items():
records = group.get("records", []) if isinstance(group, dict) else group
for record in records:
key = f"{sector_type}:{record['code']}"
history_map[key].append(self._normalize_stored_flow_amount(record.get("main_net_inflow")))
else:
for record in payload.get("records", []):
history_map[record["code"]].append(self._normalize_stored_flow_amount(record.get("main_net_inflow")))
return history_map
def _attach_rolling_metrics(self, records: list[dict], *, category: str, current_trade_date: str, is_sector: bool) -> list[dict]:
history_map = self._build_history_value_map(category, current_trade_date=current_trade_date, is_sector=is_sector)
return self._apply_rolling_metrics(records, history_map=history_map, is_sector=is_sector)
def _apply_rolling_metrics(
self,
records: list[dict],
*,
history_map: dict[str, list[float]],
is_sector: bool,
) -> list[dict]:
if not records:
return records
for record in records:
key = f"{record.get('sector_type')}:{record['code']}" if is_sector else record["code"]
previous_values = history_map.get(key, [])
current_value = float(record.get("main_net_inflow") or 0)
for window in ROLLING_WINDOWS:
field = self._rolling_field(window)
if len(previous_values) < window - 1:
record[field] = None
continue
record[field] = round(current_value + sum(previous_values[: window - 1]), 4)
return records
@staticmethod
def _has_sector_records(payload: dict) -> bool:
sector_types = payload.get("sector_types", {})
for group in sector_types.values():
records = group.get("records", []) if isinstance(group, dict) else group
if records:
return True
return False
def _parse_sector_realtime_record(self, row: dict, sector_type: str, updated_at: str) -> dict:
config = SECTOR_TYPE_CONFIG[sector_type]
return {
"trade_date": self._today(),
"sector_type": sector_type,
"sector_type_label": config["label"],
"code": row.get("f12"),
"name": row.get("f14"),
"detail_url": self._build_sector_detail_url(row.get("f12")),
"latest_price": self._safe_float(row.get("f2")),
"change_percent": self._safe_float(row.get("f3")),
"main_net_inflow": self._normalize_flow_amount(row.get("f62")),
"main_net_inflow_ratio": self._safe_float(row.get("f184")),
"super_large_net_inflow": self._normalize_flow_amount(row.get("f66")),
"super_large_net_inflow_ratio": self._safe_float(row.get("f69")),
"large_net_inflow": self._normalize_flow_amount(row.get("f72")),
"large_net_inflow_ratio": self._safe_float(row.get("f75")),
"medium_net_inflow": self._normalize_flow_amount(row.get("f78")),
"medium_net_inflow_ratio": self._safe_float(row.get("f81")),
"small_net_inflow": self._normalize_flow_amount(row.get("f84")),
"small_net_inflow_ratio": self._safe_float(row.get("f87")),
"updated_at": updated_at,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/bkzj/",
"precision": "realtime_exact",
}
def _parse_daily_kline_record(
self,
*,
code: str,
name: str,
values: str,
source_url: str,
extra: dict | None = None,
) -> dict:
parts = values.split(",")
if len(parts) < 13:
raise ValueError(f"unexpected daykline payload: {values}")
payload = {
"trade_date": parts[0],
"code": code,
"name": name,
"detail_url": self._build_sector_detail_url(code) if source_url.endswith("/bkzj/") else self._build_index_detail_url(code),
"main_net_inflow": self._normalize_flow_amount(parts[1]),
"super_large_net_inflow": self._normalize_flow_amount(parts[2]),
"large_net_inflow": self._normalize_flow_amount(parts[3]),
"medium_net_inflow": self._normalize_flow_amount(parts[4]),
"small_net_inflow": self._normalize_flow_amount(parts[5]),
"main_net_inflow_ratio": self._safe_float(parts[6]),
"super_large_net_inflow_ratio": self._safe_float(parts[7]),
"large_net_inflow_ratio": self._safe_float(parts[8]),
"medium_net_inflow_ratio": self._safe_float(parts[9]),
"small_net_inflow_ratio": self._safe_float(parts[10]),
"latest_price": self._safe_float(parts[11]),
"change_percent": self._safe_float(parts[12]),
"updated_at": self._now().isoformat(timespec="seconds"),
"source_name": "东方财富",
"source_url": source_url,
"precision": "historical_exact",
}
if extra:
payload.update(extra)
return payload
def _parse_minute_kline_record(
self,
*,
code: str,
name: str,
values: str,
source_url: str,
latest_price: float | None,
change_amount: float | None,
change_percent: float | None,
extra: dict | None = None,
) -> dict:
parts = values.split(",")
if len(parts) < 6:
raise ValueError(f"unexpected minute kline payload: {values}")
payload = {
"trade_date": parts[0].split(" ")[0],
"snapshot_time": f"{parts[0]}:00+08:00",
"code": code,
"name": name,
"detail_url": self._build_index_detail_url(code),
"latest_price": latest_price,
"change_amount": change_amount,
"change_percent": change_percent,
"main_net_inflow": self._normalize_flow_amount(parts[1]),
"super_large_net_inflow": self._normalize_flow_amount(parts[2]),
"large_net_inflow": self._normalize_flow_amount(parts[3]),
"medium_net_inflow": self._normalize_flow_amount(parts[4]),
"small_net_inflow": self._normalize_flow_amount(parts[5]),
"main_net_inflow_ratio": None,
"super_large_net_inflow_ratio": None,
"large_net_inflow_ratio": None,
"medium_net_inflow_ratio": None,
"small_net_inflow_ratio": None,
"updated_at": self._now().isoformat(timespec="seconds"),
"source_name": "东方财富",
"source_url": source_url,
"precision": "realtime_exact",
}
if extra:
payload.update(extra)
return payload
def sync_sector_realtime(self) -> dict:
updated_at = self._now().isoformat(timespec="seconds")
payload = {
"trade_date": self._today(),
"updated_at": updated_at,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/bkzj/",
"precision": "realtime_exact",
"sector_types": {},
}
raw_payloads: dict[str, list[dict]] = {}
for sector_type, config in SECTOR_TYPE_CONFIG.items():
rows = self.client.fetch_all_sector_realtime(sector_type_fs=config["fs"])
raw_payloads[sector_type] = rows
payload["sector_types"][sector_type] = {
"label": config["label"],
"records": [self._parse_sector_realtime_record(row, sector_type, updated_at) for row in rows],
}
self.repository.save_document(
"ashare_sector_realtime",
payload["trade_date"],
payload,
sort_value=payload["trade_date"],
)
self.repository.save_document(
"ashare_sector_catalog",
payload["trade_date"],
{
"trade_date": payload["trade_date"],
"updated_at": updated_at,
"sector_types": {
sector_type: [
{"code": item["code"], "name": item["name"], "sector_type": sector_type}
for item in data["records"]
]
for sector_type, data in payload["sector_types"].items()
},
},
sort_value=payload["trade_date"],
)
self.repository.save_raw_payload(f"ashare_sector_realtime_{payload['trade_date']}", raw_payloads)
for group in payload["sector_types"].values():
self._attach_rolling_metrics(
group["records"],
category="ashare_sector_daily",
current_trade_date=payload["trade_date"],
is_sector=True,
)
self.repository.save_document(
"ashare_sector_realtime",
payload["trade_date"],
payload,
sort_value=payload["trade_date"],
)
self.repository.save_document(
"ashare_sector_realtime_latest_success",
"default",
payload,
sort_value=payload["trade_date"],
)
self._persist_today_sector_daily(payload)
return payload
def sync_index_realtime(self) -> dict:
updated_at = self._now().isoformat(timespec="seconds")
records: list[dict] = []
raw_payloads: dict[str, dict] = {}
for definition in TRACKED_INDICES:
quote_payload = self.client.fetch_quote(definition["secid"])
minute_payload = self.client.fetch_fund_flow_minute_kline(definition["secid"], limit=1)
raw_payloads[definition["code"]] = {
"quote": quote_payload,
"minute": minute_payload,
}
quote_data = quote_payload.get("data") or {}
minute_data = minute_payload.get("data") or {}
minute_rows = minute_data.get("klines") or []
if not minute_rows:
continue
records.append(
self._parse_minute_kline_record(
code=definition["code"],
name=definition["name"],
values=minute_rows[-1],
source_url="https://data.eastmoney.com/zjlx/",
latest_price=self._normalize_quote_price(self._safe_int(quote_data.get("f43"))),
change_amount=self._normalize_quote_change_amount(self._safe_int(quote_data.get("f169"))),
change_percent=self._normalize_quote_change_percent(self._safe_int(quote_data.get("f170"))),
)
)
payload = {
"trade_date": self._today(),
"updated_at": updated_at,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/zjlx/",
"precision": "realtime_exact",
"records": self._attach_rolling_metrics(
records,
category="ashare_index_daily",
current_trade_date=self._today(),
is_sector=False,
),
}
self.repository.save_document(
"ashare_index_realtime",
payload["trade_date"],
payload,
sort_value=payload["trade_date"],
)
self._persist_today_index_daily(payload)
self.repository.save_raw_payload(f"ashare_index_realtime_{payload['trade_date']}", raw_payloads)
return payload
def backfill_index_daily_history(self, start_date: str | None = None) -> dict:
history_start = start_date or self.repository.get_system_config().get("history_backfill_start_date", HISTORY_START_DATE)
by_date: dict[str, list[dict]] = defaultdict(list)
raw_payloads: dict[str, dict] = {}
for definition in TRACKED_INDICES:
response = self.client.fetch_fund_flow_daykline(definition["secid"], limit=800)
raw_payloads[definition["code"]] = response
data = response.get("data") or {}
for line in data.get("klines") or []:
record = self._parse_daily_kline_record(
code=definition["code"],
name=definition["name"],
values=line,
source_url="https://data.eastmoney.com/zjlx/",
)
if record["trade_date"] < history_start:
continue
by_date[record["trade_date"]].append(record)
for trade_date, records in by_date.items():
payload = {
"trade_date": trade_date,
"updated_at": self._now().isoformat(timespec="seconds"),
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/zjlx/",
"precision": "historical_exact",
"records": sorted(records, key=lambda item: item["code"]),
}
self.repository.save_document("ashare_index_daily", trade_date, payload, sort_value=trade_date)
self.repository.save_raw_payload(f"ashare_index_daily_backfill_{self._today()}", raw_payloads)
meta = {
"last_backfill_at": self._now().isoformat(timespec="seconds"),
"start_date": history_start,
"trading_day_count": len(by_date),
"index_count": len(TRACKED_INDICES),
}
self.repository.save_document("ashare_index_history_meta", "default", meta)
return meta
def _merge_sector_daily_payload(
self,
existing: dict,
trade_date: str,
batch_groups: dict[str, list[dict]],
) -> dict:
merged_sector_types = dict(existing.get("sector_types", {}))
for sector_type, records in batch_groups.items():
existing_group = merged_sector_types.get(sector_type, {})
existing_records = existing_group.get("records", []) if isinstance(existing_group, dict) else existing_group
code_map = {item["code"]: item for item in existing_records}
for record in records:
code_map[record["code"]] = record
merged_sector_types[sector_type] = {
"label": SECTOR_TYPE_CONFIG[sector_type]["label"],
"records": sorted(code_map.values(), key=lambda item: item["code"]),
}
return {
"trade_date": trade_date,
"updated_at": self._now().isoformat(timespec="seconds"),
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/bkzj/",
"precision": "historical_exact",
"sector_types": merged_sector_types,
}
def _persist_today_index_daily(self, payload: dict) -> None:
if not payload.get("records"):
return
self.repository.save_document(
"ashare_index_daily",
payload["trade_date"],
{
"trade_date": payload["trade_date"],
"updated_at": payload.get("updated_at"),
"source_name": payload.get("source_name", "东方财富"),
"source_url": payload.get("source_url"),
"precision": "realtime_exact",
"records": sorted(payload["records"], key=lambda item: item["code"]),
},
sort_value=payload["trade_date"],
)
def _persist_today_sector_daily(self, payload: dict) -> None:
if not self._has_sector_records(payload):
return
self.repository.save_document(
"ashare_sector_daily",
payload["trade_date"],
{
"trade_date": payload["trade_date"],
"updated_at": payload.get("updated_at"),
"source_name": payload.get("source_name", "东方财富"),
"source_url": payload.get("source_url"),
"precision": "realtime_exact",
"sector_types": {
sector_type: {
"label": group.get("label", SECTOR_TYPE_CONFIG[sector_type]["label"]),
"records": sorted(group.get("records", []), key=lambda item: item["code"]),
}
for sector_type, group in payload.get("sector_types", {}).items()
},
},
sort_value=payload["trade_date"],
)
def backfill_sector_daily_history(self, start_date: str | None = None, *, batch_size: int = 120) -> dict:
history_start = start_date or self.repository.get_system_config().get("history_backfill_start_date", HISTORY_START_DATE)
catalog = self.repository.get_document("ashare_sector_catalog", self._today(), {})
if not catalog:
catalog = self.sync_sector_realtime()
sector_items: list[tuple[str, dict]] = []
sector_types = catalog.get("sector_types", {})
for sector_type, items in sector_types.items():
iterable = items["records"] if isinstance(items, dict) else items
for item in iterable:
sector_items.append((sector_type, item))
sector_items.sort(key=lambda pair: (pair[0], pair[1]["code"]))
meta_state = self.repository.get_document("ashare_sector_history_meta", "default", {})
next_sector_index = 0
if meta_state.get("start_date") == history_start and not meta_state.get("completed", False):
next_sector_index = int(meta_state.get("next_sector_index", 0))
end_sector_index = min(next_sector_index + batch_size, len(sector_items))
selected_items = sector_items[next_sector_index:end_sector_index]
by_date: dict[str, dict[str, list[dict]]] = defaultdict(lambda: defaultdict(list))
raw_payload_index: dict[str, dict] = {}
failures: list[dict] = []
for position, (sector_type, item) in enumerate(selected_items, start=1):
code = item["code"]
name = item["name"]
try:
response = self.client.fetch_fund_flow_daykline(f"90.{code}", limit=800)
except Exception as exc:
failures.append(
{
"sector_type": sector_type,
"code": code,
"name": name,
"error": str(exc),
}
)
continue
raw_payload_index[f"{sector_type}:{code}"] = {
"code": code,
"name": name,
"sector_type": sector_type,
"response_meta": {
"market": (response.get("data") or {}).get("market"),
"name": (response.get("data") or {}).get("name"),
},
}
for line in (response.get("data") or {}).get("klines") or []:
record = self._parse_daily_kline_record(
code=code,
name=name,
values=line,
source_url="https://data.eastmoney.com/bkzj/",
extra={
"sector_type": sector_type,
"sector_type_label": SECTOR_TYPE_CONFIG[sector_type]["label"],
},
)
if record["trade_date"] < history_start:
continue
by_date[record["trade_date"]][sector_type].append(record)
if position % 50 == 0:
sleep(0.5)
for trade_date, sector_groups in by_date.items():
existing = self.repository.get_document("ashare_sector_daily", trade_date, {})
payload = self._merge_sector_daily_payload(existing, trade_date, sector_groups)
self.repository.save_document("ashare_sector_daily", trade_date, payload, sort_value=trade_date)
if raw_payload_index:
self.repository.save_raw_payload(
f"ashare_sector_daily_backfill_{self._today()}_{next_sector_index}_{end_sector_index}",
raw_payload_index,
)
completed = end_sector_index >= len(sector_items)
meta = {
"last_backfill_at": self._now().isoformat(timespec="seconds"),
"start_date": history_start,
"trading_day_count": len(self.repository.list_documents("ashare_sector_daily")),
"sector_count": len(sector_items),
"batch_size": batch_size,
"processed_in_batch": len(selected_items),
"next_sector_index": 0 if completed else end_sector_index,
"completed": completed,
"failed_sector_count": len(failures),
"failures": failures[:50],
}
self.repository.save_document("ashare_sector_history_meta", "default", meta)
return meta
def get_index_realtime(self) -> dict:
payload = self.repository.get_document("ashare_index_realtime", self._today(), {})
if payload:
self._persist_today_index_daily(payload)
history_map = self._build_history_value_map(
"ashare_index_daily",
current_trade_date=payload.get("trade_date", self._today()),
is_sector=False,
)
self._apply_rolling_metrics(
payload.get("records", []),
history_map=history_map,
is_sector=False,
)
return payload
return {
"trade_date": self._today(),
"updated_at": None,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/zjlx/",
"precision": "unavailable",
"records": [],
}
def get_sector_realtime(self) -> dict:
payload = self.repository.get_document("ashare_sector_realtime", self._today(), {})
if not payload or not self._has_sector_records(payload):
payload = self.repository.get_document("ashare_sector_realtime_latest_success", "default", {})
if payload and self._has_sector_records(payload):
self._persist_today_sector_daily(payload)
return payload
for item in self.repository.list_documents("ashare_sector_realtime", limit=10):
if not self._has_sector_records(item):
continue
self._persist_today_sector_daily(item)
return item
return {
"trade_date": self._today(),
"updated_at": None,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/bkzj/",
"precision": "unavailable",
"sector_types": {},
}
def get_index_daily(self, trade_date: str | None = None) -> dict:
target_date = trade_date or self._today()
payload = self.repository.get_document("ashare_index_daily", target_date, {})
if payload:
return payload
return {
"trade_date": target_date,
"updated_at": None,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/zjlx/",
"precision": "unavailable",
"records": [],
}
def get_sector_daily(self, trade_date: str | None = None) -> dict:
target_date = trade_date or self._today()
payload = self.repository.get_document("ashare_sector_daily", target_date, {})
if payload:
return payload
return {
"trade_date": target_date,
"updated_at": None,
"source_name": "东方财富",
"source_url": "https://data.eastmoney.com/bkzj/",
"precision": "unavailable",
"sector_types": {},
}
ashare_flow_service = AShareFlowService()

View File

@ -0,0 +1,354 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from zoneinfo import ZoneInfo
from app.clients.eastmoney_client import EastmoneyClient
from app.core.config import HISTORY_START_DATE
from app.repositories.monitoring_repository import MonitoringRepository
from app.services.alert_engine import alert_engine
from app.services.market_clock import get_market_state
SOUTHBOUND_BENCHMARKS = [
{
"key": "hsi",
"label": "恒生指数",
"secid": "100.HSI",
"unit": "",
"detail_url": "https://quote.eastmoney.com/gb/zsHSI.html",
},
{
"key": "hstech",
"label": "恒生科技指数",
"secid": "124.HSTECH",
"unit": "",
"detail_url": "https://quote.eastmoney.com/gb/zsHSTECH.html",
},
]
HISTORY_BENCHMARKS = [
{
"key": "hstech_daily",
"label": "恒生科技指数",
"secid": "124.HSTECH",
"period": "daily",
}
]
class EastmoneySyncService:
def __init__(self) -> None:
self.client = EastmoneyClient()
self.repository = MonitoringRepository()
self.tz = ZoneInfo("Asia/Shanghai")
@staticmethod
def _wan_to_yi(value: float | int | None) -> float | None:
if value is None:
return None
return round(float(value) / 10000, 4)
@staticmethod
def _million_to_yi(value: float | int | None) -> float | None:
if value is None:
return None
return round(float(value) / 100, 4)
@staticmethod
def _safe_float(value: str | float | int | None) -> float | None:
if value in (None, "", "-"):
return None
return float(value)
def _determine_precision(self, trade_date: str) -> str:
now = datetime.now(self.tz)
if trade_date != now.date().isoformat():
return "historical_exact"
if now.hour > 16 or (now.hour == 16 and now.minute >= 10):
return "close_final"
return "realtime_exact"
def _parse_timeline(
self, trade_date: str, raw: list[str], precision: str
) -> tuple[list[dict], float | None, float | None]:
timeline: list[dict] = []
for item in raw:
parts = item.split(",")
if len(parts) < 10:
continue
total_net_buy = self._wan_to_yi(self._safe_float(parts[5]))
timeline.append(
{
"timestamp": f"{trade_date}T{parts[0]}:00+08:00",
"amount_hkd_billion": total_net_buy,
"precision": precision,
}
)
meaningful = [point for point in timeline if point["amount_hkd_billion"] not in (None, 0.0)]
if precision == "close_final" and not meaningful:
return [], None, None
amounts = [point["amount_hkd_billion"] for point in timeline if point["amount_hkd_billion"] is not None]
if len(amounts) < 2:
return timeline, None, None
one_min_change = round(amounts[-1] - amounts[-2], 4)
five_min_change = (
round(amounts[-1] - amounts[max(0, len(amounts) - 6)], 4)
if len(amounts) >= 6
else None
)
return timeline, one_min_change, five_min_change
def _parse_benchmark_trends(self, payload: dict, *, detail_url: str | None) -> dict:
data = payload.get("data") or {}
points: list[dict] = []
for item in data.get("trends") or []:
parts = item.split(",")
if len(parts) < 2:
continue
points.append(
{
"timestamp": f"{parts[0]}:00+08:00",
"value": self._safe_float(parts[1]),
}
)
return {
"key": data.get("code", ""),
"label": data.get("name", ""),
"unit": "",
"detail_url": detail_url,
"points": points,
}
def _build_benchmark_series(self) -> tuple[list[dict], dict[str, dict]]:
series: list[dict] = []
raw_payloads: dict[str, dict] = {}
for benchmark in SOUTHBOUND_BENCHMARKS:
response = self.client.fetch_stock_trends(benchmark["secid"], ndays=1)
raw_payloads[benchmark["key"]] = response
parsed = self._parse_benchmark_trends(response, detail_url=benchmark["detail_url"])
parsed["key"] = benchmark["key"]
parsed["label"] = benchmark["label"]
parsed["unit"] = benchmark["unit"]
series.append(parsed)
return series, raw_payloads
def _build_snapshot(self, realtime_payload: dict, timeline_payload: dict, threshold_step: float) -> dict:
south_sh = realtime_payload["data"]["sh2hk"]
south_sz = realtime_payload["data"]["sz2hk"]
trade_date = south_sh["date2"]
precision = self._determine_precision(trade_date)
updated_at = datetime.now(self.tz).isoformat(timespec="seconds")
sh_net_buy = self._wan_to_yi(south_sh.get("netBuyAmt"))
sz_net_buy = self._wan_to_yi(south_sz.get("netBuyAmt"))
total_net_buy = round((sh_net_buy or 0) + (sz_net_buy or 0), 4)
total_buy_amt = round(
(self._wan_to_yi(south_sh.get("buyAmt")) or 0) + (self._wan_to_yi(south_sz.get("buyAmt")) or 0),
4,
)
total_sell_amt = round(
(self._wan_to_yi(south_sh.get("sellAmt")) or 0) + (self._wan_to_yi(south_sz.get("sellAmt")) or 0),
4,
)
minute_timeline, one_min_change, five_min_change = self._parse_timeline(
trade_date,
timeline_payload.get("data", {}).get("n2s", []),
"realtime_exact" if precision == "realtime_exact" else precision,
)
benchmark_series, benchmark_raw_payloads = self._build_benchmark_series()
threshold_progress = 0.0 if threshold_step <= 0 else round((total_net_buy % threshold_step) / threshold_step, 4)
next_threshold = threshold_step if total_net_buy <= 0 else (int(total_net_buy // threshold_step) + 1) * threshold_step
return {
"trade_date": trade_date,
"snapshot_time": updated_at,
"market_state": get_market_state(),
"total_net_inflow": total_net_buy,
"cumulative_net_inflow": total_net_buy,
"shanghai_net_inflow": sh_net_buy,
"shenzhen_net_inflow": sz_net_buy,
"buy_amount": total_buy_amt,
"sell_amount": total_sell_amt,
"net_buy_amount": total_net_buy,
"one_min_change": one_min_change,
"five_min_change": five_min_change,
"precision": precision,
"source_name": "东方财富",
"source_url": "https://push2.eastmoney.com/api/qt/kamt/get",
"updated_at": updated_at,
"unavailable_reason": None,
"threshold_progress": threshold_progress,
"next_threshold_hkd_billion": next_threshold,
"minute_timeline": minute_timeline,
"benchmark_series": benchmark_series,
}, benchmark_raw_payloads
def _aggregate_history(self, rows: list[dict], start_date: str) -> dict:
grouped: dict[str, dict] = defaultdict(lambda: {"trade_date": "", "net": 0.0})
for row in rows:
trade_date = row["TRADE_DATE"][:10]
bucket = grouped[trade_date]
bucket["trade_date"] = trade_date
bucket["net"] += self._million_to_yi(row.get("NET_DEAL_AMT")) or 0.0
ordered = [grouped[key] for key in sorted(grouped.keys()) if key >= start_date]
daily = [{"period": item["trade_date"], "amount_hkd_billion": round(item["net"], 4)} for item in ordered]
weekly_map: dict[str, float] = defaultdict(float)
monthly_map: dict[str, float] = defaultdict(float)
cumulative: list[dict] = []
cumulative_total = 0.0
recent_trade_days: list[dict] = []
streak_in = 0
streak_out = 0
longest_in = 0
longest_out = 0
for item in ordered:
date_obj = datetime.strptime(item["trade_date"], "%Y-%m-%d")
week_key = f"{date_obj.strftime('%Y')}-W{date_obj.strftime('%W')}"
month_key = date_obj.strftime("%Y-%m")
weekly_map[week_key] += item["net"]
monthly_map[month_key] += item["net"]
cumulative_total += item["net"]
cumulative.append({"period": item["trade_date"], "amount_hkd_billion": round(cumulative_total, 4)})
if item["net"] > 0:
streak_in += 1
streak_out = 0
elif item["net"] < 0:
streak_out += 1
streak_in = 0
else:
streak_in = 0
streak_out = 0
longest_in = max(longest_in, streak_in)
longest_out = max(longest_out, streak_out)
for item in reversed(ordered[-20:]):
recent_trade_days.append(
{
"trade_date": item["trade_date"],
"total_net_inflow_hkd_billion": round(item["net"], 4),
"precision": "historical_exact",
}
)
return {
"start_date": start_date,
"daily": daily,
"weekly": [{"period": period, "amount_hkd_billion": round(value, 4)} for period, value in sorted(weekly_map.items())],
"monthly": [{"period": period, "amount_hkd_billion": round(value, 4)} for period, value in sorted(monthly_map.items())],
"cumulative": cumulative,
"benchmark_history": self._build_history_benchmarks(start_date),
"recent_trade_days": recent_trade_days,
"summary": {
"cumulative_net_inflow_hkd_billion": round(cumulative_total, 4),
"trading_day_count": len(ordered),
"max_single_day_inflow_hkd_billion": round(max((item["net"] for item in ordered), default=0), 4),
"max_single_day_outflow_hkd_billion": round(min((item["net"] for item in ordered), default=0), 4),
"longest_inflow_streak": longest_in,
"longest_outflow_streak": longest_out,
},
}
def _build_history_benchmarks(self, start_date: str) -> dict[str, list[dict]]:
benchmark_history: dict[str, list[dict]] = {}
for benchmark in HISTORY_BENCHMARKS:
response = self.client.fetch_stock_kline(benchmark["secid"], limit=160)
data = response.get("data") or {}
daily_rows = []
for line in data.get("klines") or []:
parts = line.split(",")
if len(parts) < 2 or parts[0] < start_date:
continue
daily_rows.append({"period": parts[0], "amount_hkd_billion": round(self._safe_float(parts[2]) or 0.0, 4)})
weekly_map: dict[str, float] = {}
monthly_map: dict[str, float] = {}
for item in daily_rows:
date_obj = datetime.strptime(item["period"], "%Y-%m-%d")
weekly_map[f"{date_obj.strftime('%Y')}-W{date_obj.strftime('%W')}"] = item["amount_hkd_billion"]
monthly_map[date_obj.strftime("%Y-%m")] = item["amount_hkd_billion"]
benchmark_history["hstech_daily"] = daily_rows
benchmark_history["hstech_weekly"] = [{"period": period, "amount_hkd_billion": value} for period, value in sorted(weekly_map.items())]
benchmark_history["hstech_monthly"] = [{"period": period, "amount_hkd_billion": value} for period, value in sorted(monthly_map.items())]
return benchmark_history
def sync(self, start_date: str | None = None) -> dict:
config = self.repository.get_system_config()
history_start = start_date or config.get("history_backfill_start_date", HISTORY_START_DATE)
threshold_step = float(config.get("threshold_step_hkd_billion", 50))
diagnostics = self.repository.get_source_diagnostics()
try:
realtime_payload = self.client.fetch_realtime_overview()
timeline_payload = self.client.fetch_intraday_timeline()
history_rows = self.client.fetch_history(history_start)
snapshot, benchmark_raw_payloads = self._build_snapshot(realtime_payload, timeline_payload, threshold_step)
history = self._aggregate_history(history_rows, history_start)
updated_at = datetime.now(self.tz).isoformat(timespec="seconds")
self.repository.save_snapshot(snapshot["trade_date"], snapshot)
self.repository.save_history(history)
self.repository.save_raw_payload(f"eastmoney_realtime_{snapshot['trade_date']}", realtime_payload)
self.repository.save_raw_payload(f"eastmoney_timeline_{snapshot['trade_date']}", timeline_payload)
self.repository.save_raw_payload(f"eastmoney_benchmarks_{snapshot['trade_date']}", benchmark_raw_payloads)
self.repository.save_raw_payload(f"eastmoney_history_{snapshot['trade_date']}", {"rows": history_rows})
config.update(
{
"source_name": "东方财富",
"source_strategy": "已接入东方财富历史与实时公开接口;历史来自 datacenter-web实时来自 push2。",
}
)
self.repository.save_system_config(config)
self.repository.save_source_diagnostics(
{
"source_name": "东方财富",
"realtime_available": True,
"historical_available": True,
"last_success_at": updated_at,
"last_failure_at": None,
"last_error_reason": None,
"last_success_url": "https://datacenter-web.eastmoney.com/api/data/v1/get",
"last_persisted_at": updated_at,
}
)
return {
"snapshot": snapshot,
"history_summary": history["summary"],
"history_daily_count": len(history["daily"]),
"alert_records": alert_engine.evaluate(snapshot),
}
except Exception as exc:
updated_at = datetime.now(self.tz).isoformat(timespec="seconds")
self.repository.save_source_diagnostics(
{
"source_name": "东方财富",
"realtime_available": False,
"historical_available": False,
"last_success_at": diagnostics.get("last_success_at"),
"last_failure_at": updated_at,
"last_error_reason": str(exc),
"last_success_url": diagnostics.get("last_success_url"),
"last_persisted_at": diagnostics.get("last_persisted_at"),
}
)
raise
eastmoney_sync_service = EastmoneySyncService()

View File

@ -0,0 +1,65 @@
from __future__ import annotations
import smtplib
import ssl
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from html import escape
class EmailNotificationService:
def send(
self,
*,
smtp_host: str,
smtp_port: int,
smtp_username: str,
smtp_password: str,
sender_email: str,
recipients: list[str],
subject: str,
text_body: str,
) -> None:
if not smtp_host:
raise ValueError("smtp_host 未配置")
if not smtp_username:
raise ValueError("smtp_username 未配置")
if not smtp_password:
raise ValueError("smtp_password 未配置")
if not sender_email:
raise ValueError("sender_email 未配置")
if not recipients:
raise ValueError("recipients 未配置")
message = MIMEMultipart("alternative")
message["From"] = sender_email
message["To"] = ", ".join(recipients)
message["Subject"] = str(Header(subject, "utf-8"))
html_body = (
"<html><head><meta charset=\"utf-8\"></head>"
"<body style=\"font-family:Microsoft YaHei, PingFang SC, Arial, sans-serif; line-height:1.7; font-size:14px; color:#111;\">"
f"{escape(text_body).replace(chr(10), '<br>')}"
"</body></html>"
)
message.attach(MIMEText(text_body, "plain", "utf-8"))
message.attach(MIMEText(html_body, "html", "utf-8"))
context = ssl.create_default_context()
port = int(smtp_port)
if port == 465:
with smtplib.SMTP_SSL(smtp_host, port, timeout=20, context=context) as server:
server.login(smtp_username, smtp_password)
server.sendmail(sender_email, recipients, message.as_bytes())
return
with smtplib.SMTP(smtp_host, port, timeout=20) as server:
server.ehlo()
server.starttls(context=context)
server.ehlo()
server.login(smtp_username, smtp_password)
server.sendmail(sender_email, recipients, message.as_bytes())
email_notification_service = EmailNotificationService()

View File

@ -0,0 +1,21 @@
from datetime import datetime, time
from zoneinfo import ZoneInfo
from app.api.schemas import MarketState
def get_market_state(now: datetime | None = None) -> MarketState:
current = now or datetime.now(ZoneInfo("Asia/Shanghai"))
current_time = current.time()
if current_time < time(9, 30):
return "pre_open"
if time(9, 30) <= current_time < time(12, 0):
return "trading_am"
if time(12, 0) <= current_time < time(13, 0):
return "midday_break"
if time(13, 0) <= current_time < time(16, 0):
return "trading_pm"
if time(16, 0) <= current_time < time(16, 10):
return "finalizing"
return "closed"

View File

@ -0,0 +1,209 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from app.api.schemas import (
BenchmarkTimelinePoint,
BenchmarkTimelineSeries,
HistoryResponse,
HistorySummary,
MetaResponse,
OverviewResponse,
OverviewSnapshot,
PushRecord,
PushRecordsResponse,
RecentTradeDay,
RuleItem,
RulesResponse,
SourceDiagnosticsResponse,
StatPoint,
TimelinePoint,
ValueWithStatus,
)
from app.core.config import HISTORY_START_DATE
from app.repositories.monitoring_repository import MonitoringRepository
from app.services.market_clock import get_market_state
class MonitoringService:
def __init__(self) -> None:
self.repository = MonitoringRepository()
@staticmethod
def _empty_snapshot(trade_date: str, source_name: str) -> dict:
return {
"trade_date": trade_date,
"snapshot_time": None,
"market_state": get_market_state(),
"total_net_inflow": None,
"cumulative_net_inflow": None,
"shanghai_net_inflow": None,
"shenzhen_net_inflow": None,
"buy_amount": None,
"sell_amount": None,
"net_buy_amount": None,
"one_min_change": None,
"five_min_change": None,
"precision": "unavailable",
"source_name": source_name,
"source_url": None,
"updated_at": None,
"unavailable_reason": "今天的实时快照尚未同步成功,系统不会继续展示昨天的数据。",
"threshold_progress": 0.0,
"next_threshold_hkd_billion": 50,
"minute_timeline": [],
"benchmark_series": [],
}
def get_meta(self) -> MetaResponse:
config = self.repository.get_system_config()
return MetaResponse(
product_name=config["product_name"],
version="0.1.0",
timezone=config["timezone"],
market_state=get_market_state(),
current_trade_date=datetime.now(ZoneInfo(config["timezone"])).date().isoformat(),
source_name=config["source_name"],
source_strategy=config["source_strategy"],
note="当前版本已接入真实数据同步链路,前端展示结果来自已持久化的 JSON 数据。",
)
def get_overview(self) -> OverviewResponse:
config = self.repository.get_system_config()
current_trade_date = datetime.now(ZoneInfo(config["timezone"])).date().isoformat()
payload = self.repository.get_snapshot_by_trade_date(current_trade_date)
if not payload:
payload = self._empty_snapshot(current_trade_date, config["source_name"])
precision = payload.get("precision", "unavailable")
def value(label: str, key: str) -> ValueWithStatus:
return ValueWithStatus(
amount_hkd_billion=payload.get(key),
precision=precision,
label=label,
)
snapshot = OverviewSnapshot(
trade_date=payload["trade_date"],
snapshot_time=payload.get("snapshot_time"),
market_state=payload.get("market_state", get_market_state()),
total_net_inflow=value("当日总净流入", "total_net_inflow"),
cumulative_net_inflow=value("当日累计净流入", "cumulative_net_inflow"),
shanghai_net_inflow=value("港股通(沪)净流入", "shanghai_net_inflow"),
shenzhen_net_inflow=value("港股通(深)净流入", "shenzhen_net_inflow"),
buy_amount=value("买入额", "buy_amount"),
sell_amount=value("卖出额", "sell_amount"),
net_buy_amount=value("净买额", "net_buy_amount"),
one_min_change=value("1 分钟变化", "one_min_change"),
five_min_change=value("5 分钟变化", "five_min_change"),
threshold_progress=payload.get("threshold_progress", 0),
next_threshold_hkd_billion=payload.get("next_threshold_hkd_billion", 50),
source_name=payload.get("source_name", "东方财富"),
source_url=payload.get("source_url"),
updated_at=payload.get("updated_at"),
unavailable_reason=payload.get("unavailable_reason"),
)
minute_timeline = [TimelinePoint(**item) for item in payload.get("minute_timeline", [])]
benchmark_series = [
BenchmarkTimelineSeries(
key=item["key"],
label=item["label"],
unit=item["unit"],
detail_url=item.get("detail_url"),
points=[BenchmarkTimelinePoint(**point) for point in item.get("points", [])],
)
for item in payload.get("benchmark_series", [])
]
recent_push_records = [PushRecord(**item) for item in self.repository.get_push_records().get("records", [])[:5]]
return OverviewResponse(
snapshot=snapshot,
minute_timeline=minute_timeline,
benchmark_series=benchmark_series,
recent_push_records=recent_push_records,
)
def get_history(self) -> HistoryResponse:
payload = self.repository.get_history()
summary = HistorySummary(**payload["summary"])
return HistoryResponse(
start_date=payload.get("start_date", HISTORY_START_DATE),
daily=[StatPoint(**item) for item in payload.get("daily", [])],
weekly=[StatPoint(**item) for item in payload.get("weekly", [])],
monthly=[StatPoint(**item) for item in payload.get("monthly", [])],
cumulative=[StatPoint(**item) for item in payload.get("cumulative", [])],
benchmark_history={
key: [StatPoint(**item) for item in value]
for key, value in payload.get("benchmark_history", {}).items()
},
recent_trade_days=[RecentTradeDay(**item) for item in payload.get("recent_trade_days", [])],
summary=summary,
)
def get_push_records(self) -> PushRecordsResponse:
records = [PushRecord(**item) for item in self.repository.get_push_records().get("records", [])]
return PushRecordsResponse(records=records)
def get_rules(self) -> RulesResponse:
config = self.repository.get_system_config()
items = [
RuleItem(
key="realtime_collection_interval_seconds",
label="实时采集间隔",
value=f'{config["realtime_collection_interval_seconds"]}',
description="分钟级快照拉取周期。",
),
RuleItem(
key="history_backfill_start_date",
label="历史回补起始日",
value=config["history_backfill_start_date"],
description="历史统计的回补起点。",
),
RuleItem(
key="threshold_step_hkd_billion",
label="阈值突破档位",
value=f'{config["threshold_step_hkd_billion"]} 亿港元',
description="累计净流入按该步长触发突破提醒。",
),
RuleItem(
key="five_minute_flow_alert_hkd_billion",
label="5 分钟异动阈值",
value=f'{config["five_minute_flow_alert_hkd_billion"]} 亿港元',
description="5 分钟净变化绝对值超过该阈值触发告警。",
),
RuleItem(
key="email_enabled",
label="邮件开关",
value="开启" if config["email_enabled"] else "关闭",
description="是否发送邮件推送。",
),
RuleItem(
key="sender_email",
label="发件邮箱",
value=config["sender_email"],
description="SMTP 发件人邮箱。",
),
RuleItem(
key="smtp",
label="SMTP 地址与端口",
value=f'{config["smtp_host"]}:{config["smtp_port"]}',
description="邮件服务配置。",
),
RuleItem(
key="recipients",
label="收件人列表",
value=", ".join(config["recipients"]),
description="告警接收对象。",
),
RuleItem(
key="source_name",
label="当前主数据源",
value=config["source_name"],
description="正式口径唯一主源。",
),
]
return RulesResponse(items=items)
def get_source_diagnostics(self) -> SourceDiagnosticsResponse:
return SourceDiagnosticsResponse(**self.repository.get_source_diagnostics())
monitoring_service = MonitoringService()

View File

@ -0,0 +1,76 @@
from __future__ import annotations
import threading
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.repositories.monitoring_repository import MonitoringRepository
from app.services.ashare_flow_service import ashare_flow_service
from app.services.eastmoney_sync_service import eastmoney_sync_service
from app.services.market_clock import get_market_state
class SyncScheduler:
def __init__(self) -> None:
self.repository = MonitoringRepository()
self.tz = ZoneInfo("Asia/Shanghai")
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._failure_count = 0
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="southbound-sync", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2)
def _run(self) -> None:
while not self._stop_event.is_set():
now = datetime.now(self.tz)
state = get_market_state(now)
interval_seconds = self._get_wait_seconds(now, state)
if state in {"trading_am", "trading_pm", "finalizing"}:
try:
eastmoney_sync_service.sync()
ashare_flow_service.sync_index_realtime()
ashare_flow_service.sync_sector_realtime()
self._failure_count = 0
except Exception:
self._failure_count += 1
interval_seconds = max(interval_seconds, min(180, 30 * self._failure_count))
else:
self._failure_count = 0
self._stop_event.wait(interval_seconds)
def _get_wait_seconds(self, now: datetime, state: str) -> int:
config = self.repository.get_system_config()
realtime_interval = max(int(config.get("realtime_collection_interval_seconds", 60)), 15)
if state in {"trading_am", "trading_pm", "finalizing"}:
return realtime_interval
if state == "midday_break":
return self._seconds_until(now, time(13, 0))
if state == "pre_open":
return self._seconds_until(now, time(9, 30))
return self._seconds_until_next_day_open(now)
def _seconds_until(self, now: datetime, target_time: time) -> int:
target = datetime.combine(now.date(), target_time, tzinfo=self.tz)
delta = (target - now).total_seconds()
return max(int(delta), 15)
def _seconds_until_next_day_open(self, now: datetime) -> int:
next_open = datetime.combine(now.date() + timedelta(days=1), time(9, 30), tzinfo=self.tz)
delta = (next_open - now).total_seconds()
return max(int(delta), 300)
sync_scheduler = SyncScheduler()