修改了bsp state,继续测试

This commit is contained in:
jackyu66git
2026-05-20 00:49:53 +08:00
parent 91148a648a
commit 9b876c45ed
7 changed files with 430 additions and 161 deletions
-2
View File
@@ -128,8 +128,6 @@ class ChanLun():
def get_bsp_state(self, dataframe): def get_bsp_state(self, dataframe):
return self.tf_df.get_bsp_state(dataframe) return self.tf_df.get_bsp_state(dataframe)
def get_bsp_signal_data(self, dataframe):
return self.tf_df.get_bsp_signal_data(dataframe)
def get_structure_zones(self, current_price=None, config=None): def get_structure_zones(self, current_price=None, config=None):
if config is None: if config is None:
+7 -40
View File
@@ -157,41 +157,16 @@ class TF_DF():
klu_state_list.append("00") klu_state_list.append("00")
print(klu_state_list[:20]) print(klu_state_list[:20])
return klu_state_list return klu_state_list
def get_bsp_signal_data(self, dataframe):
def get_bsp_state(self, dataframe):
klu_list = self.get_klu_list(dataframe) klu_list = self.get_klu_list(dataframe)
klc_list = self.get_klc_list(klu_list) klc_list = self.get_klc_list(klu_list)
bi_list = self.cal_bi_list(klc_list) bi_list = self.cal_bi_list(klc_list)
bi_zs_list = self.cal_bi_zs_list_pure(bi_list) seg_list = self.get_seg_list(bi_list)
bi_zs_list = self.cal_bi_zs(seg_list)
bsp_list = self.find_all_bsp(bi_list, bi_zs_list) bsp_list = self.find_all_bsp(bi_list, bi_zs_list)
bsp_by_bi_type = {}
for bsp in bsp_list:
if bsp and bsp.bi:
bsp_by_bi_type[(bsp.bi.index, bsp.type)] = bsp
bsp_state_list = [0] * len(dataframe) bsp_state_list = [0] * len(dataframe)
bsp_zg_list = [0.0] * len(dataframe)
bsp_zd_list = [0.0] * len(dataframe)
bsp_stop_price_list = [0.0] * len(dataframe)
bsp_risk_ratio_list = [0.0] * len(dataframe)
klc_index = 0 klc_index = 0
def set_bsp_signal(index, state, bsp):
bsp_state_list[index] = state
if not bsp or not bsp.zs:
return
close = float(dataframe.iloc[index]['close'])
atr = float(dataframe.iloc[index]['atr']) if 'atr' in dataframe.columns and not pd.isna(dataframe.iloc[index]['atr']) else 0.0
atr_ratio = atr / close if close > 0 else 0.0
buffer = atr * 0.1
bsp_zg_list[index] = bsp.zs.zg
bsp_zd_list[index] = bsp.zs.zd
if state == -1:
stop_price = bsp.zs.zg - buffer
risk_ratio = (close - stop_price) / close if close > stop_price else atr_ratio
else:
stop_price = bsp.zs.zd + buffer
risk_ratio = (stop_price - close) / close if close < stop_price else atr_ratio
bsp_stop_price_list[index] = stop_price
bsp_risk_ratio_list[index] = max(0.001, min(float(risk_ratio), 0.02))
for index in range(0, len(dataframe)): for index in range(0, len(dataframe)):
if klc_index == len(klc_list): if klc_index == len(klc_list):
klc_index = len(klc_list) - 1 klc_index = len(klc_list) - 1
@@ -201,7 +176,7 @@ class TF_DF():
bi = klc.bi.pre bi = klc.bi.pre
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3: if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3:
# 第三类买点 # 第三类买点
set_bsp_signal(index, -1, bsp_by_bi_type.get((bi.index, Chan_BSP_TYPE.B3))) bsp_state_list[index] = -1
#print(klc.end_time, "B3") #print(klc.end_time, "B3")
else: else:
bsp_state_list[index] = 0 bsp_state_list[index] = 0
@@ -209,22 +184,14 @@ class TF_DF():
bi = klc.bi.pre bi = klc.bi.pre
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3: if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3:
# 第三类卖点 # 第三类卖点
set_bsp_signal(index, 1, bsp_by_bi_type.get((bi.index, Chan_BSP_TYPE.S3))) bsp_state_list[index] = 1
#print(klc.end_time, "S3") #print(klc.end_time, "S3")
else: else:
bsp_state_list[index] = 0 bsp_state_list[index] = 0
klc_index += 1 klc_index += 1
else: else:
bsp_state_list[index] = 0 bsp_state_list[index] = 0
return { return bsp_state_list
'bsp_state': bsp_state_list,
'bsp_zg': bsp_zg_list,
'bsp_zd': bsp_zd_list,
'bsp_stop_price': bsp_stop_price_list,
'bsp_risk_ratio': bsp_risk_ratio_list,
}
def get_bsp_state(self, dataframe):
return self.get_bsp_signal_data(dataframe)['bsp_state']
def get_ema_state(self, dataframe): def get_ema_state(self, dataframe):
klu_list = self.get_klu_list(dataframe) klu_list = self.get_klu_list(dataframe)
klc_list = self.get_klc_list(klu_list) klc_list = self.get_klc_list(klu_list)
+10 -18
View File
@@ -1,8 +1,7 @@
""" """
engine.py - 缠论管线封装:DataFrame → KLC → BI → ZS → BSP。 engine.py - 缠论管线封装:DataFrame → KLU → KLC → BI → SEG → ZS → BSP。
复用 ~/Project/Chan/ 下的 TF_DF 模块。 复用 ~/Project/Chan/ 下的 TF_DF 模块,管线步骤对齐 TF_DF.get_bsp_state()
注意:TF_DF.__init__ 有 bugget_zs_list 不存在),这里手动调用各步骤。
""" """
import sys import sys
import os import os
@@ -13,21 +12,19 @@ if _PARENT not in sys.path:
sys.path.insert(0, _PARENT) sys.path.insert(0, _PARENT)
import pandas as pd import pandas as pd
import talib.abstract as ta
from ChanEnum import ( from ChanEnum import (
Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_BI_DIR,
Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_ZS_DIR,
) )
from ChanBSP import ChanBSP from ChanBSP import ChanBSP
from ChanBI import ChanBI from ChanBI import ChanBI
from ChanZS import ChanZS
# 仅导入类,不触发 TF_DF.__init__ # 仅导入类,不触发 TF_DF.__init__
from TF_DF import TF_DF as _TF_DF_Class from TF_DF import TF_DF as _TF_DF_Class
class ChanEngine: class ChanEngine:
"""手动执行缠论管线,绕过 TF_DF.__init__ 的 bug""" """缠论管线,对齐 TF_DF.get_bsp_state() 的调用顺序"""
def __init__(self, df: pd.DataFrame): def __init__(self, df: pd.DataFrame):
if df.empty or len(df) < 50: if df.empty or len(df) < 50:
@@ -39,18 +36,13 @@ class ChanEngine:
self.df = df self.df = df
self._tf = _TF_DF_Class.__new__(_TF_DF_Class) # 不调用 __init__ self._tf = _TF_DF_Class.__new__(_TF_DF_Class) # 不调用 __init__
# Step 0: 添加 TA 指标 # Step 0: 添加 TA 指标 (MACD/EMA/BB/RSI)
self._df_with_indicators = self._tf.add_indicators(df.copy()) self._df_with_indicators = self._tf.add_indicators(df.copy())
# Step 1: KLU (K-line unit) # Step 1: KLU — get_klu_list → get_kl_data → cal_kl_data
self.klu_list = self._tf.cal_kl_data(self._df_with_indicators) self.klu_list = self._tf.get_klu_list(self._df_with_indicators)
# Step 1.5: MACD state # Step 2: KLC — 内部已含 ChanMACD.cal_macd_state() + cal_trend()
from ChanMACD import ChanMACD
chanmacd = ChanMACD(self.klu_list)
self.klu_list = chanmacd.cal_macd_state()
# Step 2: KLC (combined K-line)
self.klc_list = self._tf.get_klc_list(self.klu_list) self.klc_list = self._tf.get_klc_list(self.klu_list)
# Step 3: BI (stroke) # Step 3: BI (stroke)
@@ -59,8 +51,8 @@ class ChanEngine:
# Step 4: SEG (segment) # Step 4: SEG (segment)
self.seg_list = self._tf.get_seg_list(self.bi_list) self.seg_list = self._tf.get_seg_list(self.bi_list)
# Step 5: ZS (bi-level center) — 供 find_all_bsp 使用 # Step 5: ZS — cal_bi_zs(seg_list) 对齐 get_bsp_state(从线段计算笔中枢)
self.bi_zs_list: List = self._tf.cal_bi_zs_list(self.bi_list) self.bi_zs_list: List = self._tf.cal_bi_zs(self.seg_list)
# Step 6: BSP (buy/sell points) # Step 6: BSP (buy/sell points)
self.bsp_list: List[ChanBSP] = self._tf.find_all_bsp( self.bsp_list: List[ChanBSP] = self._tf.find_all_bsp(
+1 -1
View File
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
# sudo docker compose run --rm chanlun_btc download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json --pairs BTC/USDT:USDT -t 1m --timerange 20240101- # sudo docker compose run --rm chanlun_btc download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json --pairs BTC/USDT:USDT -t 1m --timerange 20240101-
# sudo docker compose run --rm chanlun_btc trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies # sudo docker compose run --rm chanlun_btc trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
class ChanLun_BTC_1m(IStrategy): class ChanLun_BTC_1m_old(IStrategy):
""" """
交易核心(缠论): 交易核心(缠论):
- 仅在缠论一/二/三类买卖点出现时交易。 - 仅在缠论一/二/三类买卖点出现时交易。
+74 -29
View File
@@ -45,7 +45,7 @@ exchange = ccxt.binance({
'enableRateLimit': True, 'enableRateLimit': True,
}) })
# 初始化A股数据获取器 # 初始化 A 股数据获取器K 线优先请求 A-Share Data Platform,默认 http://103.179.242.166:8000 ,见 /api/v1/klines 文档;ASHARE_DP_URL 覆盖,置空则仅用 AKShare
china_stock = ChinaStockData() china_stock = ChinaStockData()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -65,6 +65,7 @@ def _zone_cache_ttl(tf_name: str) -> int:
else: else:
return 1800 # 4h+: 30分钟 return 1800 # 4h+: 30分钟
# 加密货币本地/自建行情服务(与 A 股 ASHARE_DP_URL 端口可不同)
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166")) DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166"))
DEFAULT_TIMEFRAME_LABELS = OrderedDict([ DEFAULT_TIMEFRAME_LABELS = OrderedDict([
@@ -150,6 +151,40 @@ def build_timeframe_labels(timeframes):
return labels return labels
def compute_timeframe_defaults(labels_ordered):
"""
根据已排序的周期 中文标签映射计算主 / / 次次周期默认值
labels_ordered: OrderedDict 或按插入顺序排列的 dict
"""
if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys())
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered and timeframe_keys:
default_main = timeframe_keys[0]
if timeframe_keys:
try:
idx = timeframe_keys.index(default_main)
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
default_element = timeframe_keys[0]
else:
default_element = default_main
if timeframe_keys:
try:
idx_el = timeframe_keys.index(default_element)
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
except ValueError:
default_sub_sub = timeframe_keys[0]
else:
default_sub_sub = default_element
return default_main, default_element, default_sub_sub, timeframe_keys
def _parse_time_input(value): def _parse_time_input(value):
if value in (None, '', 0): if value in (None, '', 0):
return None return None
@@ -230,7 +265,8 @@ def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=
refresh_data_service_metadata(force=True) refresh_data_service_metadata(force=True)
# A股热门股票 # A股热门股票
A_STOCK_SYMBOLS = china_stock.get_popular_stocks() # 模板中 A 股下拉仅放默认一项;用户切换到「A股」时由前端请求 /api/a_stocks 填充全市场(约 5500+
A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}]
def detect_symbol_type(symbol): def detect_symbol_type(symbol):
"""检测交易对类型:crypto 或 a_stock""" """检测交易对类型:crypto 或 a_stock"""
@@ -1226,39 +1262,15 @@ def serve_charting_library(filename):
def index(): def index():
"""主页""" """主页"""
refresh_data_service_metadata() refresh_data_service_metadata()
timeframe_items = list(TIMEFRAMES.items()) tf_map = TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = [item[0] for item in timeframe_items] default_main, default_element, default_sub_sub, timeframe_keys = compute_timeframe_defaults(OrderedDict(tf_map))
symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in TIMEFRAMES), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in TIMEFRAMES and timeframe_keys:
default_main = timeframe_keys[0]
if timeframe_keys:
try:
idx = timeframe_keys.index(default_main)
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
default_element = timeframe_keys[0]
else:
default_element = default_main
# 次次周期默认比次周期小一档
if timeframe_keys:
try:
idx_el = timeframe_keys.index(default_element)
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
except ValueError:
default_sub_sub = timeframe_keys[0]
else:
default_sub_sub = default_element
default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '') default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '')
return render_template( return render_template(
'index.html', 'index.html',
timeframes=TIMEFRAMES, timeframes=tf_map,
symbols=symbols, symbols=symbols,
a_stock_symbols=A_STOCK_SYMBOLS, a_stock_symbols=A_STOCK_SYMBOLS,
default_main_timeframe=default_main, default_main_timeframe=default_main,
@@ -1269,6 +1281,39 @@ def index():
data_service_available=DATA_SERVICE_AVAILABLE, data_service_available=DATA_SERVICE_AVAILABLE,
) )
@app.route('/api/chart_metadata')
def api_chart_metadata():
"""
按数据源返回图表用 K 线周期中文标签及主//次次默认周期
crypto强制刷新 DATA_SERVICE_URL /health 元信息
a_stock读取 ASHARE_DP_URL /api/v1/klines/available-freqs不修改全局加密货币 TIMEFRAMES
"""
source = (request.args.get('source') or 'crypto').strip().lower()
if source not in ('crypto', 'a_stock'):
source = 'crypto'
try:
if source == 'a_stock':
raw = china_stock.get_available_kline_freqs()
labels_od = build_timeframe_labels(raw)
else:
refresh_data_service_metadata(force=True)
labels_od = OrderedDict(TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy())
default_main, default_element, default_sub_sub, keys = compute_timeframe_defaults(labels_od)
return jsonify({
'source': source,
'timeframes': {k: v for k, v in labels_od.items()},
'timeframe_keys': keys,
'default_main': default_main,
'default_element': default_element,
'default_sub_sub': default_sub_sub,
})
except Exception as exc:
logger.exception('chart_metadata 失败: %s', exc)
return jsonify({'error': str(exc)}), 500
@app.route('/api/analyze') @app.route('/api/analyze')
def analyze(): def analyze():
"""分析接口""" """分析接口"""
+228 -19
View File
@@ -1,3 +1,4 @@
import os
import akshare as ak import akshare as ak
import pandas as pd import pandas as pd
from datetime import datetime, timedelta, time from datetime import datetime, timedelta, time
@@ -7,6 +8,14 @@ from pytz import timezone
import warnings import warnings
warnings.filterwarnings('ignore') warnings.filterwarnings('ignore')
import logging
logger = logging.getLogger(__name__)
# 与 A-Share Data Platform REST 文档一致的周期(分钟线依赖服务端积累,无数据时会回退 AKShare)
ASHARE_REST_TIMEFRAMES = frozenset({'1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'})
class ChinaStockData: class ChinaStockData:
"""A股数据获取类""" """A股数据获取类"""
@@ -17,43 +26,42 @@ class ChinaStockData:
'morning': {'start': '09:30', 'end': '11:30'}, 'morning': {'start': '09:30', 'end': '11:30'},
'afternoon': {'start': '13:00', 'end': '15:00'} 'afternoon': {'start': '13:00', 'end': '15:00'}
} }
# 例: http://103.179.242.166:8000 — 设 ASHARE_DP_URL= 空字符串可禁用,仅用 AKShare
_base = os.environ.get('ASHARE_DP_URL', 'http://103.179.242.166:8000')
self.ashare_dp_base = _base.rstrip('/') if (_base or '').strip() else ''
# 全量股票列表内存缓存(秒),默认 1 小时
try:
self.stock_list_cache_ttl = int(os.environ.get('ASHARE_STOCK_LIST_CACHE_SEC', '3600'))
except ValueError:
self.stock_list_cache_ttl = 3600
self._stock_list_cache = None
self._stock_list_cache_expires = 0.0
def get_stock_list(self): def _get_stock_list_akshare(self):
"""获取A股股票列表""" """通过 AKShare 获取 A 股列表(约 2000 条非 ST,作备用)。"""
try: try:
import requests import requests
# 设置较短的超时时间,避免长时间等待
import akshare as ak
pass
# 尝试获取沪深A股实时行情,设置超时时间
try: try:
# 临时设置requests的默认超时
original_timeout = getattr(requests, 'timeout', None) original_timeout = getattr(requests, 'timeout', None)
requests.timeout = 10 # 10秒超时 requests.timeout = 10
stock_info = ak.stock_zh_a_spot_em() stock_info = ak.stock_zh_a_spot_em()
# 恢复原始超时设置
if original_timeout: if original_timeout:
requests.timeout = original_timeout requests.timeout = original_timeout
else: else:
delattr(requests, 'timeout') delattr(requests, 'timeout')
except Exception as network_error: except Exception:
pass
# 网络失败时返回空列表,让调用方使用备用方案
return [] return []
if stock_info is None or len(stock_info) == 0: if stock_info is None or len(stock_info) == 0:
return [] return []
# 增加到前2000只股票,提供更多选择
stock_list = [] stock_list = []
for index, row in stock_info.head(2000).iterrows(): for index, row in stock_info.head(2000).iterrows():
try: try:
# 过滤掉ST股票和停牌股票
stock_name = str(row['名称']) stock_name = str(row['名称'])
if 'ST' not in stock_name and '*' not in stock_name: if 'ST' not in stock_name and '*' not in stock_name:
stock_list.append({ stock_list.append({
@@ -64,16 +72,108 @@ class ChinaStockData:
'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0, 'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0,
'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0 'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0
}) })
except Exception as row_error: except Exception:
continue continue
# 按成交金额排序,优先显示活跃股票
stock_list.sort(key=lambda x: x['amount'], reverse=True) stock_list.sort(key=lambda x: x['amount'], reverse=True)
return stock_list return stock_list
except Exception as e: except Exception:
return [] return []
def _fetch_all_stocks_ashare_dp(self):
"""分页拉取 A-Share Data Platform /api/v1/stocks 全市场标的。"""
import requests
page_size = 1000
offset = 0
all_rows = []
reported_total = None
url = f'{self.ashare_dp_base}/api/v1/stocks'
while True:
resp = requests.get(
url,
params={'limit': page_size, 'offset': offset},
timeout=45,
)
resp.raise_for_status()
payload = resp.json()
items = payload.get('items') or []
if reported_total is None:
reported_total = int(payload.get('total') or 0)
all_rows.extend(items)
if len(items) == 0:
break
if len(items) < page_size:
break
offset += page_size
if reported_total and offset >= reported_total:
break
if not all_rows:
return []
out = []
for row in all_rows:
sym = row.get('symbol')
if not sym and row.get('ts_code'):
sym = str(row['ts_code']).split('.')[0]
if not sym:
continue
name = row.get('name') or ''
out.append({
'symbol': str(sym).strip(),
'name': str(name).strip(),
'ts_code': row.get('ts_code'),
'price': 0.0,
'change_pct': 0.0,
'volume': 0.0,
'amount': 0.0,
})
out.sort(key=lambda x: x['symbol'])
return out
def get_stock_list(self, use_cache=True):
"""获取 A 股股票列表:优先全量 REST(约 5500+),失败则 AKShare。"""
now = time_module.time()
if use_cache and self._stock_list_cache is not None and now < self._stock_list_cache_expires:
return list(self._stock_list_cache)
if self.ashare_dp_base:
try:
dp_list = self._fetch_all_stocks_ashare_dp()
if dp_list:
self._stock_list_cache = dp_list
self._stock_list_cache_expires = now + self.stock_list_cache_ttl
return list(dp_list)
except Exception as exc:
logger.warning('A股列表从数据服务拉取失败,回退 AKShare: %s', exc)
ak_list = self._get_stock_list_akshare()
if ak_list:
self._stock_list_cache = ak_list
self._stock_list_cache_expires = now + min(self.stock_list_cache_ttl, 300)
return ak_list or []
def get_available_kline_freqs(self):
"""
A-Share Data Platform 支持的 K 线周期列表原始顺序不保证由上层按粒度排序
文档: GET /api/v1/klines/available-freqs
"""
import requests
fallback = ['1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M']
if not self.ashare_dp_base:
return list(fallback)
try:
url = f'{self.ashare_dp_base}/api/v1/klines/available-freqs'
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
freqs = data.get('frequencies') or []
return list(freqs) if freqs else list(fallback)
except Exception as exc:
logger.warning('获取 A 股可用 K 线周期失败: %s', exc)
return list(fallback)
def get_popular_stocks(self): def get_popular_stocks(self):
"""获取热门A股股票代码列表 - 扩展版本,按行业分类""" """获取热门A股股票代码列表 - 扩展版本,按行业分类"""
return [ return [
@@ -194,6 +294,110 @@ class ChinaStockData:
} }
return mapping.get(timeframe, 'daily') return mapping.get(timeframe, 'daily')
@staticmethod
def symbol_to_ts_code(symbol):
"""六位代码或已是 ts_code(000001.SZ)→ 交易所后缀。"""
if symbol is None:
return ''
s = str(symbol).strip().upper()
if '.' in s and s.count('.') == 1:
return s
if len(s) != 6 or not s.isdigit():
return s
if s.startswith('6'):
return f'{s}.SH'
if s.startswith(('0', '3')):
return f'{s}.SZ'
if s.startswith('920'):
return f'{s}.BJ'
if s.startswith(('8', '4')):
return f'{s}.BJ'
return f'{s}.SZ'
@staticmethod
def _ymd_compact_to_api_date(ymd_compact):
"""YYYYMMDD → YYYY-MM-DD"""
if not ymd_compact or len(ymd_compact) != 8:
return None
return f'{ymd_compact[:4]}-{ymd_compact[4:6]}-{ymd_compact[6:8]}'
def get_kl_data_from_ashare_dp(self, symbol, timeframe, start_date, end_date, limit):
"""
A-Share Data Platform/api/v1/klines/{freq}拉取 K 线
start_date / end_date YYYYMMDD 字符串
"""
if not self.ashare_dp_base or timeframe not in ASHARE_REST_TIMEFRAMES:
return None
import requests
ts_code = self.symbol_to_ts_code(symbol)
if not ts_code or '.' not in ts_code:
return None
start_api = self._ymd_compact_to_api_date(start_date)
end_api = self._ymd_compact_to_api_date(end_date)
if not start_api or not end_api:
return None
api_limit = 10000
if limit is not None:
try:
api_limit = min(int(limit), 10000)
except (TypeError, ValueError):
api_limit = 10000
url = f'{self.ashare_dp_base}/api/v1/klines/{timeframe}'
params = {
'ts_code': ts_code,
'start_date': start_api,
'end_date': end_api,
'limit': api_limit,
}
try:
resp = requests.get(url, params=params, timeout=20)
resp.raise_for_status()
payload = resp.json()
except Exception as exc:
logger.debug('A股数据服务 K 线请求失败: %s', exc)
return None
items = payload.get('items') or payload.get('data') or []
if not items:
return None
rows = []
for row in items:
t = row.get('trade_time') or row.get('trade_date')
if not t:
continue
rows.append({
'date': t,
'open': row.get('open'),
'high': row.get('high'),
'low': row.get('low'),
'close': row.get('close'),
'volume': row.get('volume'),
})
if not rows:
return None
df = pd.DataFrame(rows)
df['date'] = pd.to_datetime(df['date'])
for col in ('open', 'high', 'low', 'close', 'volume'):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')
df = df.dropna(subset=['open', 'high', 'low', 'close'])
df = df.sort_values('date').reset_index(drop=True)
df = self.adjust_timestamp_for_trading_hours(df, timeframe)
df = self.clean_a_stock_data(df, timeframe)
if df is None or len(df) == 0:
return None
if limit is not None:
try:
lim = int(limit)
if len(df) > lim:
df = df.tail(lim).reset_index(drop=True)
except (TypeError, ValueError):
pass
elif len(df) > 10000:
df = df.tail(10000).reset_index(drop=True)
df = self.add_indicators(df)
return df
def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000): def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000):
""" """
获取A股K线数据 - 支持分批次获取突破单次限制 获取A股K线数据 - 支持分批次获取突破单次限制
@@ -222,7 +426,12 @@ class ChinaStockData:
if '-' in end_date: if '-' in end_date:
end_date = end_date.replace('-', '') end_date = end_date.replace('-', '')
pass if self.ashare_dp_base:
df_dp = self.get_kl_data_from_ashare_dp(
symbol, timeframe, start_date, end_date, limit
)
if df_dp is not None and len(df_dp) > 0:
return df_dp
# 分批次获取数据以突破单次限制 # 分批次获取数据以突破单次限制
all_data = [] all_data = []
+110 -52
View File
@@ -2015,6 +2015,40 @@
} }
} }
/** 应用 /api/chart_metadata 返回的周期列表(切换 crypto / A股 时拉取) */
function applyChartMetadata(meta) {
if (!meta || meta.error || !Array.isArray(meta.timeframe_keys) || meta.timeframe_keys.length === 0) {
return;
}
window.AVAILABLE_TIMEFRAMES = meta.timeframe_keys;
window.DEFAULT_MAIN_TIMEFRAME = meta.default_main;
window.DEFAULT_ELEMENT_TIMEFRAME = meta.default_element;
window.DEFAULT_SUB_SUB_TIMEFRAME = meta.default_sub_sub;
const labels = meta.timeframes || {};
function refill(selId, preferredVal) {
const $el = $(selId);
const cur = $el.val();
$el.empty();
meta.timeframe_keys.forEach(function(k) {
$el.append($('<option>', { value: k, text: labels[k] || k }));
});
const pick = (cur && meta.timeframe_keys.indexOf(cur) >= 0) ? cur : preferredVal;
if (pick && meta.timeframe_keys.indexOf(pick) >= 0) {
$el.val(pick);
} else {
$el.val(meta.timeframe_keys[0]);
}
}
refill('#timeframe', meta.default_main);
refill('#elementTimeframe', meta.default_element);
refill('#subSubTimeframe', meta.default_sub_sub);
const mainTf = $('#timeframe').val();
if (compareTimeframes($('#elementTimeframe').val(), mainTf) > 0) {
setSmallestLargerTimeframe(mainTf);
}
ensureSubSubLteElement();
}
// 比较两个时间周期的大小 // 比较两个时间周期的大小
function compareTimeframes(tf1, tf2) { function compareTimeframes(tf1, tf2) {
const v1 = window.timeframeToMs(tf1); const v1 = window.timeframeToMs(tf1);
@@ -8069,43 +8103,64 @@
console.log('从本地存储恢复时区设置:', savedTimezone); console.log('从本地存储恢复时区设置:', savedTimezone);
} }
// 初始化数据源切换 // 初始化数据源切换:先按数据源重新拉取周期元信息,再切换 UI
$('#dataSource').on('change', function() { $('#dataSource').on('change', function() {
const dataSource = $(this).val(); const dataSource = $(this).val();
if (dataSource === 'crypto') { const apiSrc = dataSource === 'a_stock' ? 'a_stock' : 'crypto';
$('#cryptoSymbolContainer').show(); $.getJSON('/api/chart_metadata', { source: apiSrc })
$('#astockSymbolContainer').hide(); .done(function(meta) {
// 停止A股状态更新器 applyChartMetadata(meta);
if (window.astockStatusInterval) { })
clearInterval(window.astockStatusInterval); .always(function() {
window.astockStatusInterval = null; if (dataSource === 'crypto') {
} $('#cryptoSymbolContainer').show();
} else if (dataSource === 'a_stock') { $('#astockSymbolContainer').hide();
$('#cryptoSymbolContainer').hide(); if (window.astockStatusInterval) {
$('#astockSymbolContainer').show(); clearInterval(window.astockStatusInterval);
// 加载A股数据时,如果还没有加载股票列表,可以在这里触发加载 window.astockStatusInterval = null;
loadAStockSymbols(); }
// 启动A股交易时间状态更新器 loadSymbols();
startAStockStatusUpdater(); } else if (dataSource === 'a_stock') {
} $('#cryptoSymbolContainer').hide();
$('#astockSymbolContainer').show();
loadAStockSymbols();
startAStockStatusUpdater();
}
});
}); });
// 检查初始数据源设置 // 检查初始数据源设置
const initialDataSource = $('#dataSource').val(); const initialDataSource = $('#dataSource').val();
if (initialDataSource === 'a_stock') { if (initialDataSource === 'a_stock') {
startAStockStatusUpdater(); $.getJSON('/api/chart_metadata', { source: 'a_stock' })
.done(function(meta) {
applyChartMetadata(meta);
})
.always(function() {
loadAStockSymbols();
startAStockStatusUpdater();
setTimeout(function() {
updateChart();
}, 300);
});
} else {
setTimeout(function() {
updateChart();
}, 500);
} }
// 初始化交易对下拉菜单 // 初始化交易对下拉菜单
$('#symbol').val('BTC/USDT:USDT'); $('#symbol').val('BTC/USDT:USDT');
$('#astockSymbol').val('000001'); $('#astockSymbol').val('000001');
const mainDefault = window.DEFAULT_MAIN_TIMEFRAME || $('#timeframe option:first').val(); if (initialDataSource !== 'a_stock') {
const elementDefault = window.DEFAULT_ELEMENT_TIMEFRAME || $('#elementTimeframe option:first').val(); const mainDefault = window.DEFAULT_MAIN_TIMEFRAME || $('#timeframe option:first').val();
if (mainDefault) { const elementDefault = window.DEFAULT_ELEMENT_TIMEFRAME || $('#elementTimeframe option:first').val();
$('#timeframe').val(mainDefault); if (mainDefault) {
} $('#timeframe').val(mainDefault);
if (elementDefault) { }
$('#elementTimeframe').val(elementDefault); if (elementDefault) {
$('#elementTimeframe').val(elementDefault);
}
} }
// 测试打印时区偏移量 // 测试打印时区偏移量
@@ -8127,10 +8182,12 @@
// 尝试加载更多交易对 // 尝试加载更多交易对
loadSymbols(); loadSymbols();
// 初始化图表并加载默认数据 // 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 updateChart
setTimeout(function() { if (initialDataSource !== 'a_stock') {
updateChart(); setTimeout(function() {
}, 500); updateChart();
}, 500);
}
// 初始化自动刷新功能 // 初始化自动刷新功能
initAutoRefresh(); initAutoRefresh();
@@ -8634,31 +8691,32 @@
}) })
// 获取A股股票列表 // 获取A股股票列表(全市场,来自 /api/a_stocks
function loadAStockSymbols() { function loadAStockSymbols() {
$.get('/api/popular_a_stocks', function(data) { const $select = $('#astockSymbol');
if (Array.isArray(data)) { const currentSymbol = $select.val();
const $select = $('#astockSymbol'); $select.prop('disabled', true);
const currentSymbol = $select.val(); // 保存当前选中的值 $.get('/api/a_stocks', function(data) {
$select.empty(); $select.prop('disabled', false);
if (!Array.isArray(data)) {
data.forEach(function(stock) { console.error('加载A股股票列表失败: 返回非数组', data);
$select.append($('<option>', { return;
value: stock.symbol,
text: stock.symbol + ' - ' + stock.name
}));
});
// 如果有保存的选中值,恢复它
if (currentSymbol && data.some(stock => stock.symbol === currentSymbol)) {
$select.val(currentSymbol);
} else {
// 设置默认值为平安银行
$select.val('000001');
}
} }
}).fail(function() { $select.empty();
console.error('加载A股股票列表失败'); data.forEach(function(stock) {
$select.append($('<option>', {
value: stock.symbol,
text: stock.symbol + ' - ' + (stock.name || '')
}));
});
if (currentSymbol && data.some(stock => stock.symbol === currentSymbol)) {
$select.val(currentSymbol);
} else {
$select.val('000001');
}
}).fail(function(xhr) {
$select.prop('disabled', false);
console.error('加载A股股票列表失败', xhr && xhr.status);
}); });
} }
// 检测交易对类型并返回相应的配置 // 检测交易对类型并返回相应的配置