@@ -5,8 +5,11 @@ import logging
from contextlib import suppress
from dataclasses import dataclass , field
from datetime import datetime , timedelta
from time import time
from pathlib import Path
from typing import Dict , List , Optional , Tuple , Union
from collections import defaultdict
from threading import Event , RLock
from typing import Dict , List , Optional , Set , Tuple , Union
import ccxt
import ccxt . async_support as ccxt_async
@@ -24,12 +27,7 @@ try:
except ImportError : # pragma: no cover - 环境缺失依赖时自动降级
resample_to_interval = None # type: ignore
from . storage import (
ensure_storage ,
read_candles ,
upsert_candles ,
get_last_timestamp ,
)
from . storage import candle_path , ensure_storage , read_candles , write_candles_snapshot
LOG_LEVEL = os . environ . get ( " LOG_LEVEL " , " INFO " ) . upper ( )
@@ -46,10 +44,8 @@ WS_ENABLED = os.environ.get("WS_ENABLED", "false").lower() in {"1", "true", "yes
REST_POLL_INTERVAL = max ( 1.0 , float ( os . environ . get ( " REST_POLL_INTERVAL " , " 5 " ) ) )
REST_POLL_WINDOW = max ( 1 , int ( os . environ . get ( " REST_POLL_WINDOW " , " 10 " ) ) )
REST_MAX_CONCURRENCY = int ( os . environ . get ( " REST_MAX_CONCURRENCY " , " 4 " ) )
VERIFY_MAX_CONCURRENCY = int ( os . environ . get ( " VERIFY_MAX_CONCURRENCY " , " 2 " ) )
REST_MAX_CONCURRENCY = int ( os . environ . get ( " REST_MAX_CONCURRENCY " , " 1 " ) )
REST_FETCH_SEMAPHORE = asyncio . Semaphore ( max ( 1 , REST_MAX_CONCURRENCY ) )
VERIFY_FETCH_SEMAPHORE = asyncio . Semaphore ( max ( 1 , VERIFY_MAX_CONCURRENCY ) )
AGGREGATION_PLAN : Dict [ str , List [ str ] ] = {
" 1m " : [ " 2m " , " 3m " , " 4m " , " 5m " , " 10m " , " 15m " , " 20m " , " 25m " , " 30m " ] ,
@@ -62,6 +58,300 @@ AGGREGATION_PLAN: Dict[str, List[str]] = {
CandleRow = List [ Union [ int , float ] ]
CANDLE_COLUMNS = [ " timestamp " , " open " , " high " , " low " , " close " , " volume " ]
CANDLES_CACHE : Dict [ Tuple [ str , str ] , pd . DataFrame ] = { }
CACHE_LOCK = RLock ( )
BASE_TIMEFRAMES : Set [ str ] = set ( )
BASE_DIRTY_VERSION : Dict [ Tuple [ str , str ] , int ] = { }
BASE_FLUSH_INTERVAL_SECONDS = 600
CACHE_FILE_MTIME : Dict [ Tuple [ str , str ] , float ] = { }
IS_ENGINE_PROCESS = os . environ . get ( " DATASVC_ENGINE " ) == " 1 "
def _empty_frame ( ) - > pd . DataFrame :
return pd . DataFrame ( columns = CANDLE_COLUMNS )
def _normalize_dataframe ( df : pd . DataFrame ) - > pd . DataFrame :
if df . empty :
return _empty_frame ( )
normalized = df . copy ( )
missing_columns = [ col for col in CANDLE_COLUMNS if col not in normalized . columns ]
for column in missing_columns :
normalized [ column ] = 0.0 if column != " timestamp " else 0
normalized = normalized [ CANDLE_COLUMNS ]
normalized [ " timestamp " ] = normalized [ " timestamp " ] . astype ( " int64 " )
for column in CANDLE_COLUMNS [ 1 : ] :
normalized [ column ] = normalized [ column ] . astype ( " float64 " )
normalized = normalized . drop_duplicates ( subset = [ " timestamp " ] , keep = " last " ) . sort_values ( " timestamp " ) . reset_index ( drop = True )
return normalized
def preload_candles_cache ( symbols : List [ str ] , timeframes : List [ str ] ) - > None :
new_cache : Dict [ Tuple [ str , str ] , pd . DataFrame ] = { }
new_mtime : Dict [ Tuple [ str , str ] , float ] = { }
for symbol in symbols :
for timeframe in timeframes :
df = read_candles ( DATA_DIR , symbol , timeframe , None , None )
normalized = _normalize_dataframe ( df )
new_cache [ ( symbol , timeframe ) ] = normalized
try :
mtime = os . path . getmtime ( candle_path ( DATA_DIR , symbol , timeframe ) )
except OSError :
mtime = 0.0
new_mtime [ ( symbol , timeframe ) ] = mtime
with CACHE_LOCK :
CANDLES_CACHE . clear ( )
CANDLES_CACHE . update ( new_cache )
BASE_DIRTY_VERSION . clear ( )
CACHE_FILE_MTIME . clear ( )
for key in new_cache :
if key [ 1 ] in BASE_TIMEFRAMES :
BASE_DIRTY_VERSION [ key ] = 0
CACHE_FILE_MTIME [ key ] = new_mtime . get ( key , 0.0 )
def refresh_cache_from_disk ( symbol : str , timeframe : str ) - > None :
if timeframe not in BASE_TIMEFRAMES :
return
if IS_ENGINE_PROCESS :
return
key = ( symbol , timeframe )
path = candle_path ( DATA_DIR , symbol , timeframe )
try :
mtime = os . path . getmtime ( path )
except FileNotFoundError :
with CACHE_LOCK :
if key not in CANDLES_CACHE :
CANDLES_CACHE [ key ] = _empty_frame ( )
CACHE_FILE_MTIME [ key ] = 0.0
return
except OSError :
return
with CACHE_LOCK :
cached_mtime = CACHE_FILE_MTIME . get ( key , 0.0 )
if mtime < = cached_mtime :
return
df = read_candles ( DATA_DIR , symbol , timeframe , None , None )
normalized = _normalize_dataframe ( df )
with CACHE_LOCK :
CANDLES_CACHE [ key ] = normalized
CACHE_FILE_MTIME [ key ] = mtime
if timeframe in BASE_TIMEFRAMES :
BASE_DIRTY_VERSION [ key ] = 0
def update_cache_mtime ( symbol : str , timeframe : str ) - > None :
path = candle_path ( DATA_DIR , symbol , timeframe )
try :
mtime = os . path . getmtime ( path )
except OSError :
mtime = time ( )
with CACHE_LOCK :
CACHE_FILE_MTIME [ ( symbol , timeframe ) ] = mtime
def rebuild_all_derived_timeframes ( symbols : List [ str ] ) - > None :
if not RESAMPLE_AVAILABLE :
return
for symbol in symbols :
for base_tf , targets in AGGREGATION_TARGETS . items ( ) :
if not targets :
continue
resample_and_store ( symbol , base_tf , targets )
def cache_get ( symbol : str , timeframe : str , start : Optional [ int ] = None , end : Optional [ int ] = None ) - > pd . DataFrame :
refresh_cache_from_disk ( symbol , timeframe )
key = ( symbol , timeframe )
with CACHE_LOCK :
df = CANDLES_CACHE . get ( key )
if df is None :
df = _empty_frame ( )
result = df
if start is not None :
result = result [ result [ " timestamp " ] > = int ( start ) ]
if end is not None :
result = result [ result [ " timestamp " ] < = int ( end ) ]
return result . copy ( )
def cache_get_last_timestamp ( symbol : str , timeframe : str ) - > Optional [ int ] :
if timeframe in BASE_TIMEFRAMES :
refresh_cache_from_disk ( symbol , timeframe )
key = ( symbol , timeframe )
with CACHE_LOCK :
df = CANDLES_CACHE . get ( key )
if df is None :
CANDLES_CACHE [ key ] = _empty_frame ( )
return None
if df . empty :
return None
return int ( df [ " timestamp " ] . iloc [ - 1 ] )
def cache_update ( symbol : str , timeframe : str , candles : List [ CandleRow ] ) - > Optional [ pd . DataFrame ] :
if not candles :
return None
new_df = _normalize_dataframe ( pd . DataFrame ( candles , columns = CANDLE_COLUMNS ) )
if new_df . empty :
return None
key = ( symbol , timeframe )
with CACHE_LOCK :
existing = CANDLES_CACHE . get ( key )
if existing is None or existing . empty :
merged = new_df
else :
merged = pd . concat ( [ existing , new_df ] , ignore_index = True )
merged = _normalize_dataframe ( merged )
with CACHE_LOCK :
CANDLES_CACHE [ key ] = merged
if timeframe in BASE_TIMEFRAMES :
BASE_DIRTY_VERSION [ key ] = BASE_DIRTY_VERSION . get ( key , 0 ) + 1
snapshot = merged . copy ( )
return snapshot
def collect_engine_status ( ) - > dict :
updated_at = datetime . utcnow ( ) . replace ( microsecond = 0 ) . isoformat ( ) + " Z "
tasks = [ state . to_payload ( ) for state in fetch_states . values ( ) ]
return {
" updated_at " : updated_at ,
" tasks " : tasks ,
" queues " : { } ,
}
def write_engine_status_snapshot ( ) - > None :
try :
ENGINE_STATUS_PATH . parent . mkdir ( parents = True , exist_ok = True )
snapshot = collect_engine_status ( )
ENGINE_STATUS_PATH . write_text ( json . dumps ( snapshot , ensure_ascii = False ) , encoding = " utf-8 " )
except Exception :
logger . warning ( " 写入引擎状态快照失败 " , exc_info = True )
async def status_flush_worker ( stop_event : Event , interval : float = 5.0 ) - > None :
await asyncio . to_thread ( write_engine_status_snapshot )
try :
while not stop_event . is_set ( ) :
await asyncio . sleep ( interval )
await asyncio . to_thread ( write_engine_status_snapshot )
except asyncio . CancelledError :
raise
def load_engine_status_snapshot ( ) - > Optional [ dict ] :
try :
content = ENGINE_STATUS_PATH . read_text ( encoding = " utf-8 " )
except FileNotFoundError :
return None
except Exception :
logger . warning ( " 读取引擎状态快照失败 " , exc_info = True )
return None
try :
return json . loads ( content )
except json . JSONDecodeError :
logger . warning ( " 解析引擎状态快照失败 " )
return None
async def flush_dirty_base_snapshots ( force_all : bool = False ) - > None :
with CACHE_LOCK :
if force_all :
target_entries = [ ]
for key in CANDLES_CACHE . keys ( ) :
symbol , timeframe = key
if timeframe in BASE_TIMEFRAMES :
version = BASE_DIRTY_VERSION . get ( key , 0 )
target_entries . append ( ( key , version ) )
else :
target_entries = [ ( key , version ) for key , version in BASE_DIRTY_VERSION . items ( ) if version > 0 ]
snapshots = { key : CANDLES_CACHE . get ( key , _empty_frame ( ) ) . copy ( ) for key , _ in target_entries }
if not snapshots :
return
failed : Set [ Tuple [ str , str ] ] = set ( )
for key , snapshot in snapshots . items ( ) :
symbol , timeframe = key
try :
await asyncio . to_thread ( write_candles_snapshot , DATA_DIR , symbol , timeframe , snapshot )
except Exception :
failed . add ( key )
logger . exception (
" 基础周期快照写入失败 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe } ,
)
else :
update_cache_mtime ( symbol , timeframe )
if not failed :
logger . debug (
" 基础周期快照写入完成 " ,
extra = { " count " : len ( snapshots ) , " force_all " : force_all } ,
)
with CACHE_LOCK :
for key , version in target_entries :
if key in failed :
continue
current_version = BASE_DIRTY_VERSION . get ( key , 0 )
if current_version == version :
BASE_DIRTY_VERSION [ key ] = 0
async def base_flush_worker ( ) :
try :
logger . info (
" 基础周期定时写盘任务已启动 " ,
extra = { " interval_seconds " : BASE_FLUSH_INTERVAL_SECONDS } ,
)
while True :
await asyncio . sleep ( BASE_FLUSH_INTERVAL_SECONDS )
await flush_dirty_base_snapshots ( )
except asyncio . CancelledError :
raise
finally :
with suppress ( Exception ) :
await flush_dirty_base_snapshots ( force_all = True )
async def run_engine ( stop_event : Optional [ Event ] = None ) :
if stop_event is None :
stop_event = Event ( )
logger . info ( " 数据引擎启动 " )
fetch_tasks . clear ( )
try :
await asyncio . to_thread ( rebuild_all_derived_timeframes , SYMBOLS )
except Exception :
logger . exception ( " 初始化衍生周期失败,继续启动引擎 " )
try :
status_task = asyncio . create_task ( status_flush_worker ( stop_event ) , name = " status::flush " )
fetch_tasks . append ( status_task )
flush_task = asyncio . create_task ( base_flush_worker ( ) , name = " flush::base " )
fetch_tasks . append ( flush_task )
for s in SYMBOLS :
for tf in FETCH_TIMEFRAMES :
fetch_task = asyncio . create_task ( fetch_loop ( s , tf ) , name = f " fetch:: { s } :: { tf } " )
fetch_tasks . append ( fetch_task )
while not stop_event . is_set ( ) :
await asyncio . sleep ( 1.0 )
finally :
stop_event . set ( )
if fetch_tasks :
logger . info ( " 数据引擎正在停止 " )
tasks = list ( fetch_tasks )
for task in tasks :
task . cancel ( )
results = await asyncio . gather ( * tasks , return_exceptions = True )
for result in results :
if isinstance ( result , Exception ) and not isinstance ( result , asyncio . CancelledError ) :
logger . warning ( " 任务停止时出现异常: %s " , result )
fetch_tasks . clear ( )
with suppress ( Exception ) :
await flush_dirty_base_snapshots ( force_all = True )
with suppress ( Exception ) :
await asyncio . to_thread ( write_engine_status_snapshot )
logger . info ( " 数据引擎已停止 " )
def _split_env_list ( value : str ) - > List [ str ] :
return [ item . strip ( ) for item in value . split ( " , " ) if item . strip ( ) ]
@@ -158,6 +448,7 @@ for base_tf in FETCH_TIMEFRAMES:
AVAILABLE_TIMEFRAMES . append ( derived_tf )
DERIVED_TIMEFRAMES = [ tf for tf in AVAILABLE_TIMEFRAMES if tf not in FETCH_TIMEFRAMES ]
AGGREGATION_TARGETS = { tf : AGGREGATION_PLAN . get ( tf , [ ] ) for tf in FETCH_TIMEFRAMES }
BASE_TIMEFRAMES = set ( FETCH_TIMEFRAMES )
START_FROM = os . environ . get ( " START_FROM " , " 2025-09-01 " ) # 首次启动拉取起始日期(UTC)
POLL_FACTOR = float ( os . environ . get ( " POLL_FACTOR " , " 0.5 " ) ) # 轮询间隔 = tf_ms * factor
@@ -165,40 +456,18 @@ BACKOFF_BASE = float(os.environ.get("BACKOFF_BASE", "2.0"))
BACKOFF_MAX = float ( os . environ . get ( " BACKOFF_MAX " , " 30.0 " ) )
BINANCE_WS_BASE = os . environ . get ( " BINANCE_WS_BASE " , " wss://fstream.binance.com/ws " ) . rstrip ( " / " )
BASE_FLUSH_INTERVAL_MINUTES = max ( 1 , int ( os . environ . get ( " BASE_FLUSH_INTERVAL_MINUTES " , " 10 " ) ) )
BASE_FLUSH_INTERVAL_SECONDS = BASE_FLUSH_INTERVAL_MINUTES * 60
ENGINE_STATUS_PATH = Path ( DATA_DIR ) / " engine_status.json "
VALID_SYMBOLS = set ( SYMBOLS )
VALID_TIMEFRAMES = set ( AVAILABLE_TIMEFRAMES )
ensure_storage ( DATA_DIR )
preload_candles_cache ( SYMBOLS , AVAILABLE_TIMEFRAMES )
def _load_verify_intervals ( ) - > Dict [ str , int ] :
mapping : Dict [ str , int ] = { }
raw = os . environ . get ( " VERIFY_INTERVALS " , " " )
if not raw :
return mapping
parts = [ item . strip ( ) for item in raw . split ( " , " ) if item . strip ( ) ]
for part in parts :
if " = " not in part :
continue
key , value = part . split ( " = " , 1 )
key = key . strip ( )
value = value . strip ( )
if not key or not value :
continue
try :
parsed = int ( value )
except ValueError :
logger . warning ( " 解析 VERIFY_INTERVALS 失败,已忽略条目 " , extra = { " entry " : part } )
continue
if parsed < = 0 :
continue
mapping [ key ] = parsed
return mapping
DEFAULT_VERIFY_INTERVAL_MULTIPLIER = max ( 1 , int ( os . environ . get ( " VERIFY_DEFAULT_INTERVAL " , " 1 " ) ) )
VERIFY_INTERVAL_MULTIPLIERS = _load_verify_intervals ( )
app = FastAPI ( title = " Local Data Service " , version = " 0.1.0 " )
app . add_middleware (
CORSMiddleware ,
@@ -283,24 +552,16 @@ class Hub:
hub = Hub ( )
fetch_tasks : List [ asyncio . Task ] = [ ]
verification_queues : Dict [ Tuple [ str , str ] , asyncio . Queue [ " VerificationJob " ] ] = { }
engine_runner_stop : Optional [ Event ] = None
engine_runner_task : Optional [ asyncio . Task ] = None
def resample_and_store ( symbol : str , base_timeframe : str , derived_timeframes : List [ str ] ) - > List [ Tuple [ str , List [ CandleRow ] ] ] :
if not RESAMPLE_AVAILABLE or not derived_timeframes :
if not derived_timeframes :
return [ ]
base_df = read_candles ( DATA_DIR , symbol , base_timeframe , None , None )
if base_df . empty :
return [ ]
base_df = base_df . copy ( )
if " date " not in base_df . columns :
base_df [ " date " ] = pd . to_datetime ( base_df [ " timestamp " ] , unit = " ms " , utc = True )
base_df = (
base_df . drop_duplicates ( subset = [ " timestamp " ] , keep = " last " )
. sort_values ( " timestamp " )
. reset_index ( drop = True )
)
base_df [ " timestamp " ] = base_df [ " timestamp " ] . astype ( " int64 " )
base_tf_ms = tf_to_ms ( base_timeframe )
updates : List [ Tuple [ str , List [ List [ float ] ] ] ] = [ ]
for target_tf in derived_timeframes :
@@ -308,8 +569,31 @@ def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: Lis
if minutes is None :
logger . warning ( " 无法解析聚合周期 " , extra = { " target_timeframe " : target_tf } )
continue
last_ts = cache_get_last_timestamp ( symbol , target_tf )
start_ts : Optional [ int ] = None
if last_ts is not None and base_tf_ms is not None and base_tf_ms > 0 :
buffer_ms = minutes * 60_000 + base_tf_ms
start_ts = max ( 0 , int ( last_ts ) - buffer_ms )
base_slice = cache_get ( symbol , base_timeframe , start_ts , None )
if base_slice . empty :
continue
base_slice = base_slice . copy ( )
if " date " not in base_slice . columns :
base_slice [ " date " ] = pd . to_datetime ( base_slice [ " timestamp " ] , unit = " ms " , utc = True )
base_slice = (
base_slice . drop_duplicates ( subset = [ " timestamp " ] , keep = " last " )
. sort_values ( " timestamp " )
. reset_index ( drop = True )
)
base_slice [ " timestamp " ] = base_slice [ " timestamp " ] . astype ( " int64 " )
try :
derived_df = resample_to_interval ( base_df , minutes ) # type: ignore[misc]
if RESAMPLE_AVAILABLE :
derived_df = resample_to_interval ( base_slice , minutes ) # type: ignore[misc]
else :
derived_df = _fallback_resample_to_interval ( base_slice , minutes )
except Exception :
logger . exception (
" 聚合周期计算失败 " ,
@@ -341,7 +625,6 @@ def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: Lis
continue
derived_df [ " timestamp " ] = derived_df [ " timestamp " ] . astype ( " int64 " )
derived_df = derived_df . sort_values ( " timestamp " )
last_ts = get_last_timestamp ( DATA_DIR , symbol , target_tf )
if last_ts is not None :
derived_df = derived_df [ derived_df [ " timestamp " ] > last_ts ]
if derived_df . empty :
@@ -361,7 +644,7 @@ def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: Lis
)
if not records :
continue
upsert_candles ( DATA_DIR , symbol , target_tf , records )
cache_update ( symbol , target_tf , records )
updates . append ( ( target_tf , records [ - 3 : ] if len ( records ) > 3 else records ) )
return updates
@@ -412,6 +695,8 @@ def compute_live_derived_updates(
if not candles or not derived_timeframes or base_tf_ms < = 0 :
return updates
pending_updates : Dict [ str , List [ CandleRow ] ] = defaultdict ( list )
derived_ms_map : Dict [ str , int ] = { }
max_multiplier = 1
for target_tf in derived_timeframes :
@@ -431,7 +716,7 @@ def compute_live_derived_updates(
if base_start < 0 :
base_start = 0
base_df = read_candles ( DATA_DIR , symbol , base_timeframe , base_start , newest_ts )
base_df = cache_get ( symbol , base_timeframe , base_start , newest_ts )
if base_df . empty :
return updates
base_df = base_df . sort_values ( " timestamp " )
@@ -502,11 +787,13 @@ def compute_live_derived_updates(
float ( subset [ " volume " ] . sum ( ) ) ,
]
closed = last_closed_ts is not None and last_closed_ts > = end_ts
upsert_candles ( DATA_DIR , symbol , target_tf , [ row ] )
pending_updates [ target_tf ] . append ( row )
rows_with_status . append ( ( row , closed ) )
current_start + = derived_ms
if rows_with_status :
updates [ target_tf ] = rows_with_status
for target_tf , rows in pending_updates . items ( ) :
cache_update ( symbol , target_tf , rows )
return updates
@@ -519,7 +806,6 @@ class FetchState:
last_candle_ts : Optional [ int ] = None
consecutive_errors : int = 0
last_error : Optional [ str ] = None
last_verified_ts : Optional [ int ] = None
def to_payload ( self ) - > dict :
def serialize_dt ( dt : Optional [ datetime ] ) - > Optional [ str ] :
@@ -535,68 +821,12 @@ class FetchState:
" last_candle_ts " : self . last_candle_ts ,
" consecutive_errors " : self . consecutive_errors ,
" last_error " : self . last_error ,
" last_verified_ts " : self . last_verified_ts ,
}
@dataclass
class VerificationJob :
timestamp : int
count : int = 1
fetch_states : Dict [ Tuple [ str , str ] , FetchState ] = { }
def enqueue_verification_job ( symbol : str , timeframe : str , timestamp : int , count : int ) - > None :
state_key = ( symbol , timeframe )
queue = verification_queues . get ( state_key )
if queue is None :
return
state = fetch_states . get ( state_key )
if state is None :
state = FetchState ( symbol = symbol , timeframe = timeframe )
fetch_states [ state_key ] = state
tf_ms = tf_to_ms ( timeframe )
interval_multiplier = VERIFY_INTERVAL_MULTIPLIERS . get ( timeframe , DEFAULT_VERIFY_INTERVAL_MULTIPLIER )
min_gap_ms : Optional [ int ] = None
if tf_ms and tf_ms > 0 :
min_gap_ms = tf_ms * max ( 1 , interval_multiplier )
if state . last_verified_ts is not None and timestamp < = state . last_verified_ts :
logger . debug (
" 跳过校验任务,已验证更晚时间 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : timestamp , " last_verified " : state . last_verified_ts } ,
)
return
if min_gap_ms is not None and state . last_verified_ts is not None :
gap = timestamp - state . last_verified_ts
if gap < min_gap_ms :
logger . debug (
" 跳过校验任务,间隔不足 " ,
extra = {
" symbol " : symbol ,
" timeframe " : timeframe ,
" timestamp " : timestamp ,
" last_verified " : state . last_verified_ts ,
" required_gap_ms " : min_gap_ms ,
" actual_gap_ms " : gap ,
} ,
)
return
job = VerificationJob ( timestamp = int ( timestamp ) , count = max ( 1 , int ( count ) ) )
try :
queue . put_nowait ( job )
logger . info (
" 排入校验任务 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : timestamp , " count " : count } ,
)
except asyncio . QueueFull :
logger . warning (
" 验证队列已满,丢弃校验任务 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : timestamp , " count " : count } ,
)
async def process_candles (
symbol : str ,
timeframe : str ,
@@ -604,7 +834,6 @@ async def process_candles(
derived_timeframes : List [ str ] ,
tf_ms : int ,
finalized : bool ,
allow_verification : bool ,
closed_flags : Optional [ List [ bool ] ] = None ,
) - > None :
if not candles :
@@ -612,7 +841,7 @@ async def process_candles(
if closed_flags is None or len ( closed_flags ) != len ( candles ) :
closed_flags = [ finalized ] * len ( candles )
state_key = ( symbol , timeframe )
upsert_candles ( DATA_DIR , symbol , timeframe , candles )
cache_update ( symbol , timeframe , candles )
base_records = list ( zip ( candles , closed_flags ) )
last_closed_ts : Optional [ int ] = None
for row , is_closed in base_records :
@@ -633,10 +862,9 @@ async def process_candles(
)
derived_updates : List [ Tuple [ str , List [ CandleRow ] ] ] = [ ]
live_derived_updates : Dict [ str , List [ Tuple [ CandleRow , bool ] ] ] = { }
derived_closed_ts : Dict [ str , int ] = { }
if derived_timeframes :
needs_resample = finalized or len ( candles ) > 1
if needs_resample and RESAMPLE_AVAILABLE :
if needs_resample :
derived_updates = await asyncio . to_thread (
resample_and_store ,
symbol ,
@@ -650,7 +878,7 @@ async def process_candles(
" symbol " : symbol ,
" base_timeframe " : timeframe ,
" targets " : [ item [ 0 ] for item in derived_updates ] ,
" origin " : " resample " ,
" origin " : " resample " if RESAMPLE_AVAILABLE else " fallback " ,
} ,
)
live_derived_updates = await asyncio . to_thread (
@@ -699,10 +927,6 @@ async def process_candles(
derived_closed = last_closed_ts_for_derived is not None and last_closed_ts_for_derived > = ts + target_tf_ms - tf_ms
else :
derived_closed = last_closed_ts_for_derived is not None and last_closed_ts_for_derived > = ts
if derived_closed :
previous = derived_closed_ts . get ( target_tf )
if previous is None or ts > previous :
derived_closed_ts [ target_tf ] = ts
derived_state_key = ( symbol , target_tf )
derived_state = fetch_states . get ( derived_state_key )
if derived_state is None :
@@ -732,10 +956,6 @@ async def process_candles(
continue
for row , derived_closed in items :
ts = int ( row [ 0 ] )
if derived_closed :
previous = derived_closed_ts . get ( target_tf )
if previous is None or ts > previous :
derived_closed_ts [ target_tf ] = ts
derived_state_key = ( symbol , target_tf )
derived_state = fetch_states . get ( derived_state_key )
if derived_state is None :
@@ -765,16 +985,7 @@ async def process_candles(
state . last_candle_ts = candles [ - 1 ] [ 0 ]
state . consecutive_errors = 0
state . last_error = None
last_closed_flag = closed_flags [ - 1 ] if closed_flags else finalized
if allow_verification and last_closed_flag :
latest_ts = candles [ - 1 ] [ 0 ]
now_ms = int ( datetime . utcnow ( ) . timestamp ( ) * 1000 )
if latest_ts > 0 and now_ms - latest_ts < = 2 * tf_ms :
range_count = 10 if timeframe == " 1m " else 1
enqueue_verification_job ( symbol , timeframe , latest_ts , range_count )
if allow_verification and derived_closed_ts :
for target_tf , closed_ts in derived_closed_ts . items ( ) :
enqueue_verification_job ( symbol , target_tf , closed_ts , 10 )
# 验证逻辑已移除,衍生周期的缺口依赖轮询补齐
async def rest_catchup (
@@ -836,7 +1047,6 @@ async def rest_catchup(
derived_timeframes ,
tf_ms ,
finalized = True ,
allow_verification = False ,
closed_flags = [ True ] * len ( candles ) ,
)
state . consecutive_errors = 0
@@ -950,7 +1160,6 @@ async def rest_poll_loop(
derived_timeframes ,
tf_ms ,
finalized = True ,
allow_verification = True ,
closed_flags = closed_flags ,
)
except asyncio . CancelledError :
@@ -1004,7 +1213,6 @@ async def stream_loop(symbol: str, timeframe: str, derived_timeframes: List[str]
derived_timeframes ,
tf_ms ,
finalized = is_closed ,
allow_verification = is_closed ,
closed_flags = [ is_closed ] ,
)
except asyncio . CancelledError :
@@ -1055,21 +1263,9 @@ async def fetch_loop(symbol: str, timeframe: str):
tf_ms = tf_to_ms ( timeframe )
state_key = ( symbol , timeframe )
last_ts = get_last_timestamp ( DATA_DIR , symbol , timeframe )
last_ts = cache_get_last_timestamp ( symbol , timeframe )
fetch_states [ state_key ] = FetchState ( symbol = symbol , timeframe = timeframe , last_candle_ts = last_ts )
queue = verification_queues . get ( state_key )
if queue is None :
queue = asyncio . Queue ( maxsize = 500 )
verification_queues [ state_key ] = queue
if last_ts is not None and last_ts > 0 :
initial_count = 10 if timeframe == " 1m " else 1
try :
queue . put_nowait ( VerificationJob ( timestamp = last_ts , count = initial_count ) )
except asyncio . QueueFull :
logger . warning (
" 重启后无法排入校验任务,队列已满 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : last_ts , " count " : initial_count } ,
)
initial_sync_flushed = False
start_from = parse_start_from_ms ( START_FROM )
backoff = 1.0
@@ -1093,6 +1289,13 @@ async def fetch_loop(symbol: str, timeframe: str):
try :
await rest_catchup ( symbol , timeframe , derived_timeframes , tf_ms , initial_since )
if not initial_sync_flushed :
await flush_dirty_base_snapshots ( force_all = True )
logger . info (
" 初次同步完成,基础周期数据已写盘 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe } ,
)
initial_sync_flushed = True
if WS_ENABLED :
await stream_loop ( symbol , timeframe , derived_timeframes , tf_ms )
else :
@@ -1121,158 +1324,34 @@ async def fetch_loop(symbol: str, timeframe: str):
logger . info ( " 拉取任务退出 " , extra = { " symbol " : symbol , " timeframe " : timeframe } )
async def verification_worker ( symbol : str , timeframe : str ) :
state_key = ( symbol , timeframe )
queue = verification_queues . get ( state_key )
if queue is None :
return
interval_ms = tf_to_ms ( timeframe )
exchange = build_exchange ( )
try :
while True :
job = await queue . get ( )
try :
state = fetch_states . get ( state_key )
if state and state . last_verified_ts is not None and job . timestamp < = state . last_verified_ts :
logger . debug (
" 跳过校验任务,时间戳已验证 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : job . timestamp } ,
)
continue
verify_count = max ( 1 , job . count )
if interval_ms < = 0 :
interval_ms = tf_to_ms ( timeframe )
start_ts = job . timestamp - ( verify_count - 1 ) * interval_ms
if start_ts < 0 :
start_ts = 0
limit = max ( verify_count + 2 , 2 )
async with VERIFY_FETCH_SEMAPHORE :
fetched = await exchange . fetch_ohlcv ( symbol , timeframe , since = start_ts , limit = limit )
normalized , _ = normalize_candles_for_timeframe ( fetched , interval_ms )
if not normalized :
logger . warning (
" 验证未获取到任何数据 " ,
extra = {
" symbol " : symbol ,
" timeframe " : timeframe ,
" timestamp " : job . timestamp ,
" count " : verify_count ,
} ,
)
continue
logger . info (
" 开始校验 K 线 " ,
extra = {
" symbol " : symbol ,
" timeframe " : timeframe ,
" timestamp " : job . timestamp ,
" count " : verify_count ,
} ,
)
remote_map = { int ( row [ 0 ] ) : row for row in normalized }
target_ts_list = [ start_ts + i * interval_ms for i in range ( verify_count ) ]
local_df = read_candles ( DATA_DIR , symbol , timeframe , start_ts , job . timestamp )
updated = False
for ts in target_ts_list :
candidate = remote_map . get ( ts )
if candidate is None :
logger . warning (
" 验证缺失远端数据 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : ts } ,
)
continue
stored_rows = local_df [ local_df [ " timestamp " ] == ts ]
needs_upsert = stored_rows . empty
reason = " missing "
if not needs_upsert :
stored_row = stored_rows . iloc [ 0 ]
diffs = (
abs ( float ( stored_row [ " open " ] ) - float ( candidate [ 1 ] ) ) ,
abs ( float ( stored_row [ " high " ] ) - float ( candidate [ 2 ] ) ) ,
abs ( float ( stored_row [ " low " ] ) - float ( candidate [ 3 ] ) ) ,
abs ( float ( stored_row [ " close " ] ) - float ( candidate [ 4 ] ) ) ,
abs ( float ( stored_row [ " volume " ] ) - float ( candidate [ 5 ] ) ) ,
)
if any ( diff > 1e-9 for diff in diffs ) :
needs_upsert = True
reason = " mismatch "
if needs_upsert :
upsert_candles ( DATA_DIR , symbol , timeframe , [ candidate ] )
updated = True
logger . info (
" 验证回补完成 " ,
extra = { " symbol " : symbol , " timeframe " : timeframe , " timestamp " : ts , " reason " : reason } ,
)
state = fetch_states . get ( state_key )
if state :
if state . last_verified_ts is None or job . timestamp > state . last_verified_ts :
state . last_verified_ts = job . timestamp
if updated :
state . last_error = None
except asyncio . CancelledError :
raise
except Exception as exc :
logger . warning (
" 验证请求失败 " ,
extra = {
" symbol " : symbol ,
" timeframe " : timeframe ,
" timestamp " : job . timestamp ,
" count " : job . count ,
" error " : str ( exc ) ,
} ,
)
await asyncio . sleep ( 1.0 )
finally :
queue . task_done ( )
except asyncio . CancelledError :
raise
finally :
with suppress ( Exception ) :
await exchange . close ( )
logger . info ( " 验证任务退出 " , extra = { " symbol " : symbol , " timeframe " : timeframe } )
@app.on_event ( " startup " )
async def on_start ( ) :
ensure_storage ( DATA_DIR )
fetch_tasks . clear ( )
verification_queues . clear ( )
for s in SYMBOLS :
for tf in FETCH_TIMEFRAMES :
state_key = ( s , tf )
verification_queues [ state_key ] = asyncio . Queue ( maxsize = 500 )
task = asyncio . create_task ( fetch_loop ( s , tf ) , name = f " fetch:: { s } :: { tf } " )
fetch_tasks . append ( task )
verify_task = asyncio . create_task ( verification_worker ( s , tf ) , name = f " verify:: { s } :: { tf } " )
fetch_tasks . append ( verify_task )
for tf in DERIVED_TIMEFRAMES :
state_key = ( s , tf )
if state_key not in verification_queues :
verification_queues [ state_key ] = asyncio . Queue ( maxsize = 500 )
verify_task = asyncio . create_task ( verification_worker ( s , tf ) , name = f " verify:: { s } :: { tf } " )
fetch_tasks . append ( verify_task )
logger . info ( " API 服务启动完成 " )
if IS_ENGINE_PROCESS :
global engine_runner_stop , engine_runner_task
if engine_runner_task is None or engine_runner_task . done ( ) :
engine_runner_stop = Event ( )
engine_runner_task = asyncio . create_task ( run_engine ( engine_runner_stop ) )
@app.on_event ( " shutdown " )
async def on_shutdown ( ) :
if not fetch_tasks :
return
logger . info ( " 正在停止拉取任务 " )
tasks = list ( fetch_tasks )
for task in tasks :
task . cancel ( )
results = await asyncio . gather ( * tasks , return_exceptions = True )
for result in results :
if isinstance ( result , Exception ) and not isinstance ( result , asyncio . CancelledError ) :
logger . warning ( " 任务停止时出现异常: %s " , result )
fetch_tasks . clear ( )
verification_queues . clear ( )
logger . info ( " API 服务准备退出 " )
if IS_ENGINE_PROCESS :
global engine_runner_stop , engine_runner_task
if engine_runner_stop is not None :
engine_runner_stop . set ( )
if engine_runner_task is not None :
with suppress ( Exception ) :
await engine_runner_task
engine_runner_task = None
engine_runner_stop = None
@app.get ( " /health " )
async def health ( ) :
now = datetime . utcnow ( ) . replace ( microsecond = 0 ) . isoformat ( ) + " Z "
engine_status = load_engine_status_snapshot ( ) or { " updated_at " : None , " tasks " : [ ] , " queues " : { } }
return {
" status " : " ok " ,
" time " : now ,
@@ -1281,7 +1360,8 @@ async def health():
" base_timeframes " : FETCH_TIMEFRAMES ,
" derived_timeframes " : DERIVED_TIMEFRAMES ,
" timeframes " : AVAILABLE_TIMEFRAMES ,
" tasks " : [ state . to_payload ( ) for state in fetch_states . values ( ) ] ,
" engine " : engine_status ,
" tasks " : engine_status . get ( " tasks " , [ ] ) ,
}
@@ -1294,7 +1374,7 @@ def api_candles(
) :
try :
ensure_symbol_timeframe ( symbol , tf )
df = read_candles ( DATA_DIR , symbol , tf , start , end )
df = cache_get ( symbol , tf , start , end )
records = df . to_dict ( " records " ) if not df . empty else [ ]
return JSONResponse ( records )
except Exception as e :
@@ -1308,7 +1388,7 @@ async def ws_endpoint(websocket: WebSocket, symbol: str, tf: str, since: Optiona
return
await hub . subscribe ( websocket , symbol , tf )
try :
snap = read_candles ( DATA_DIR , symbol , tf , since , None )
snap = cache_get ( symbol , tf , since , None )
await websocket . send_text (
json . dumps (
{
@@ -1344,3 +1424,34 @@ def root():
}
def _fallback_resample_to_interval ( df : pd . DataFrame , minutes : int ) - > pd . DataFrame :
if df . empty or minutes < = 0 :
return pd . DataFrame ( columns = CANDLE_COLUMNS )
working = df . copy ( )
if " timestamp " not in working . columns :
return pd . DataFrame ( columns = CANDLE_COLUMNS )
working [ " date " ] = pd . to_datetime ( working [ " timestamp " ] , unit = " ms " , utc = True )
working = working . set_index ( " date " , drop = True )
columns = [ " open " , " high " , " low " , " close " , " volume " ]
for column in columns :
if column not in working . columns :
working [ column ] = 0.0
working = working [ columns ]
rule = f " { minutes } T "
aggregated = working . resample ( rule , label = " left " , closed = " left " ) . agg (
{
" open " : " first " ,
" high " : " max " ,
" low " : " min " ,
" close " : " last " ,
" volume " : " sum " ,
}
)
aggregated = aggregated . dropna ( subset = [ " open " , " high " , " low " , " close " ] ) . reset_index ( )
aggregated [ " timestamp " ] = ( aggregated [ " date " ] . astype ( " int64 " ) / / 1_000_000 )
aggregated = aggregated . drop ( columns = [ " date " ] , errors = " ignore " )
aggregated = aggregated . dropna ( subset = [ " timestamp " ] ) . reset_index ( drop = True )
aggregated [ " timestamp " ] = aggregated [ " timestamp " ] . astype ( " int64 " )
return aggregated [ CANDLE_COLUMNS ]