355 lines
15 KiB
Python
355 lines
15 KiB
Python
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()
|