Initial commit
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
79
backend/app/api/routes.py
Normal file
79
backend/app/api/routes.py
Normal 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
201
backend/app/api/schemas.py
Normal 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]
|
||||
1
backend/app/clients/__init__.py
Normal file
1
backend/app/clients/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
259
backend/app/clients/eastmoney_client.py
Normal file
259
backend/app/clients/eastmoney_client.py
Normal 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",
|
||||
},
|
||||
)
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
126
backend/app/core/bootstrap.py
Normal file
126
backend/app/core/bootstrap.py
Normal 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)
|
||||
16
backend/app/core/config.py
Normal file
16
backend/app/core/config.py
Normal 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
38
backend/app/main.py
Normal 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()
|
||||
1
backend/app/repositories/__init__.py
Normal file
1
backend/app/repositories/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
22
backend/app/repositories/json_repository.py
Normal file
22
backend/app/repositories/json_repository.py
Normal 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)
|
||||
196
backend/app/repositories/monitoring_repository.py
Normal file
196
backend/app/repositories/monitoring_repository.py
Normal 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)
|
||||
95
backend/app/repositories/mysql_repository.py
Normal file
95
backend/app/repositories/mysql_repository.py
Normal 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]
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
120
backend/app/services/alert_engine.py
Normal file
120
backend/app/services/alert_engine.py
Normal 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()
|
||||
195
backend/app/services/alert_service.py
Normal file
195
backend/app/services/alert_service.py
Normal 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()
|
||||
670
backend/app/services/ashare_flow_service.py
Normal file
670
backend/app/services/ashare_flow_service.py
Normal 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()
|
||||
354
backend/app/services/eastmoney_sync_service.py
Normal file
354
backend/app/services/eastmoney_sync_service.py
Normal 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()
|
||||
65
backend/app/services/email_notification_service.py
Normal file
65
backend/app/services/email_notification_service.py
Normal 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()
|
||||
21
backend/app/services/market_clock.py
Normal file
21
backend/app/services/market_clock.py
Normal 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"
|
||||
209
backend/app/services/monitoring_service.py
Normal file
209
backend/app/services/monitoring_service.py
Normal 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()
|
||||
76
backend/app/services/sync_scheduler.py
Normal file
76
backend/app/services/sync_scheduler.py
Normal 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()
|
||||
Reference in New Issue
Block a user