添加k线动能理论
This commit is contained in:
+245
-3
@@ -267,6 +267,18 @@ def add_indicators(df):
|
||||
df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0)
|
||||
df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0)
|
||||
df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0)
|
||||
# 新增 EMA 指标
|
||||
df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0)
|
||||
df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0)
|
||||
df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0)
|
||||
df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0)
|
||||
# 常用SMA 24/52
|
||||
try:
|
||||
df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0)
|
||||
df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0)
|
||||
except Exception:
|
||||
df['sma24'] = 0
|
||||
df['sma52'] = 0
|
||||
df['rsi'] = ta.RSI(df, timeperiod=14)
|
||||
|
||||
# 计算布林带 (当前周期 - 20周期,2标准差)
|
||||
@@ -275,9 +287,11 @@ def add_indicators(df):
|
||||
df['bb_middle'] = bb['middleband'].fillna(0)
|
||||
df['bb_lower'] = bb['lowerband'].fillna(0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
df['bbup30'] = bb30['upperband'].fillna(0)
|
||||
df['bblow30'] = bb30['lowerband'].fillna(0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
df['bbup302'] = bb302['upperband'].fillna(0)
|
||||
df['bblow302'] = bb302['lowerband'].fillna(0)
|
||||
# 计算次周期布林带 (14周期,2标准差)
|
||||
@@ -293,6 +307,12 @@ def add_indicators(df):
|
||||
df['ma10'] = df['ma10'].fillna(0)
|
||||
df['ma30'] = df['ma30'].fillna(0)
|
||||
df['ma250'] = df['ma250'].fillna(0)
|
||||
df['ema5'] = df['ema5'].fillna(0)
|
||||
df['ema10'] = df['ema10'].fillna(0)
|
||||
df['ema24'] = df['ema24'].fillna(0)
|
||||
df['ema52'] = df['ema52'].fillna(0)
|
||||
df['sma24'] = df['sma24'].fillna(0)
|
||||
df['sma52'] = df['sma52'].fillna(0)
|
||||
df['rsi'] = df['rsi'].fillna(0)
|
||||
df['avg_volume'] = df['volume'].rolling(10).mean()
|
||||
# 计算量比,避免产生Infinity值
|
||||
@@ -312,10 +332,10 @@ def add_indicators(df):
|
||||
|
||||
def calculate_macd(df):
|
||||
"""计算MACD指标"""
|
||||
exp1 = df['close'].ewm(span=24, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span=52, adjust=False).mean()
|
||||
exp1 = df['close'].ewm(span=12, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span=26, adjust=False).mean()
|
||||
macd = exp1 - exp2
|
||||
signal = macd.ewm(span=18, adjust=False).mean()
|
||||
signal = macd.ewm(span=9, adjust=False).mean()
|
||||
histogram = macd - signal
|
||||
|
||||
return {
|
||||
@@ -1035,6 +1055,228 @@ def clean_dataframe_for_json(df):
|
||||
|
||||
return clean_df
|
||||
|
||||
# ====== 趋势判定与趋势筛选(币对) ======
|
||||
|
||||
def classify_trend_stage(df):
|
||||
"""根据 EMA 斜率与多空排列判断趋势方向与阶段
|
||||
返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100)
|
||||
"""
|
||||
if df is None or len(df) < 60:
|
||||
return "sideways", "early", 0
|
||||
|
||||
# 使用 EMA5/10/24/52
|
||||
closes = df['close'].values
|
||||
ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5)
|
||||
ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10)
|
||||
ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24)
|
||||
ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52)
|
||||
|
||||
# 最近N根用于斜率与排列判定
|
||||
lookback = min(30, len(df) - 1)
|
||||
if lookback <= 5:
|
||||
return "sideways", "early", 0
|
||||
|
||||
# 简单斜率: 最近k根的线性变化率近似
|
||||
def slope(arr, k=10):
|
||||
k = min(k, len(arr) - 1)
|
||||
if k < 2:
|
||||
return 0.0
|
||||
y = arr[-k:]
|
||||
x = np.arange(k)
|
||||
# 最小二乘拟合斜率
|
||||
denom = np.dot(x - x.mean(), x - x.mean())
|
||||
if denom == 0:
|
||||
return 0.0
|
||||
m = np.dot(y - y.mean(), x - x.mean()) / denom
|
||||
return float(m)
|
||||
|
||||
k_slope = 12 # 斜率窗口
|
||||
s5 = slope(ema5, k_slope)
|
||||
s10 = slope(ema10, k_slope)
|
||||
s24 = slope(ema24, k_slope)
|
||||
s52 = slope(ema52, k_slope)
|
||||
|
||||
# 多空排列
|
||||
last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1]
|
||||
bull_stack = last5 > last10 > last24 > last52
|
||||
bear_stack = last5 < last10 < last24 < last52
|
||||
|
||||
# 波动性与动量增强: MACD 柱体最近均值
|
||||
macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram']
|
||||
hist_recent = macdhist[-lookback:]
|
||||
hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0
|
||||
|
||||
# 方向
|
||||
if bull_stack and s24 > 0 and s52 > 0:
|
||||
direction = "bull"
|
||||
elif bear_stack and s24 < 0 and s52 < 0:
|
||||
direction = "bear"
|
||||
else:
|
||||
# 用价格相对 EMA52 辅助
|
||||
if closes[-1] > last52 and (s24 + s52) > 0:
|
||||
direction = "bull"
|
||||
elif closes[-1] < last52 and (s24 + s52) < 0:
|
||||
direction = "bear"
|
||||
else:
|
||||
direction = "sideways"
|
||||
|
||||
# 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛)
|
||||
dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0
|
||||
slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化
|
||||
dist_score = min(50.0, abs(dist52) * 200.0)
|
||||
hist_score = min(30.0, hist_power * 10.0)
|
||||
strength = float(min(100.0, slope_score + dist_score + hist_score))
|
||||
|
||||
# 简单阶段判定
|
||||
if direction == "sideways":
|
||||
stage = "early"
|
||||
strength = min(strength, 30.0)
|
||||
else:
|
||||
# 查看最近 hist 是否在扩大或收敛
|
||||
if len(hist_recent) >= 6:
|
||||
recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3]))
|
||||
else:
|
||||
recent_growth = 0.0
|
||||
|
||||
if recent_growth > 0 and abs(dist52) < 0.05:
|
||||
stage = "early"
|
||||
elif recent_growth > 0 and abs(dist52) >= 0.05:
|
||||
stage = "mid"
|
||||
else:
|
||||
stage = "late"
|
||||
|
||||
return direction, stage, strength
|
||||
|
||||
|
||||
def load_crypto_symbols(limit=200):
|
||||
"""加载常见USDT永续合约交易对,返回列表"""
|
||||
try:
|
||||
markets = exchange.load_markets()
|
||||
symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s]
|
||||
return symbols[:limit]
|
||||
except Exception:
|
||||
return SYMBOLS
|
||||
|
||||
|
||||
@app.route('/api/trend_filter', methods=['GET'])
|
||||
def trend_filter():
|
||||
"""趋势筛选接口(币对)
|
||||
参数:
|
||||
timeframe: K线周期
|
||||
start_time, end_time: 毫秒时间戳,可选
|
||||
direction: bull/bear/sideways 可选
|
||||
stage: early/mid/late 可选
|
||||
min_strength: 0-100 可选
|
||||
symbols: 逗号分隔列表,可选;不传则自动加载部分USDT币对
|
||||
返回符合条件的币对与简要统计
|
||||
"""
|
||||
timeframe = request.args.get('timeframe', '1h')
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
want_direction = request.args.get('direction') # 可为 None
|
||||
want_stage = request.args.get('stage') # 可为 None
|
||||
try:
|
||||
min_strength = float(request.args.get('min_strength', '0'))
|
||||
except ValueError:
|
||||
min_strength = 0.0
|
||||
|
||||
symbols_param = request.args.get('symbols')
|
||||
if symbols_param:
|
||||
symbols_list = [s.strip() for s in symbols_param.split(',') if s.strip()]
|
||||
else:
|
||||
symbols_list = load_crypto_symbols(limit=150)
|
||||
|
||||
results = []
|
||||
for sym in symbols_list:
|
||||
try:
|
||||
df = get_crypto_kl_data(sym, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
df = add_indicators(df)
|
||||
direction, stage, strength = classify_trend_stage(df)
|
||||
|
||||
if want_direction and direction != want_direction:
|
||||
continue
|
||||
if want_stage and stage != want_stage:
|
||||
continue
|
||||
if strength < min_strength:
|
||||
continue
|
||||
|
||||
last_row = df.iloc[-1]
|
||||
results.append({
|
||||
'symbol': sym,
|
||||
'time': int(last_row['timestamp']),
|
||||
'close': float(last_row['close']),
|
||||
'direction': direction,
|
||||
'stage': stage,
|
||||
'strength': float(round(strength, 2)),
|
||||
'ema5': float(last_row['ema5']),
|
||||
'ema10': float(last_row['ema10']),
|
||||
'ema24': float(last_row['ema24']),
|
||||
'ema52': float(last_row['ema52'])
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 按强度降序
|
||||
results.sort(key=lambda x: x['strength'], reverse=True)
|
||||
return jsonify({
|
||||
'count': len(results),
|
||||
'results': results
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/trend_detail', methods=['GET'])
|
||||
def trend_detail():
|
||||
"""返回单个币对的K线与EMA、用于前端绘制趋势线
|
||||
参数: symbol, timeframe, start_time, end_time
|
||||
"""
|
||||
symbol = request.args.get('symbol')
|
||||
timeframe = request.args.get('timeframe', '1h')
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
timezone_name = request.args.get('timezone', 'Asia/Shanghai')
|
||||
|
||||
if not symbol:
|
||||
return jsonify({'error': 'symbol不能为空'})
|
||||
|
||||
df = get_crypto_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None or len(df) == 0:
|
||||
return jsonify({'error': '获取数据失败'})
|
||||
|
||||
df = add_indicators(df)
|
||||
direction, stage, strength = classify_trend_stage(df)
|
||||
|
||||
# 简单趋势线: 用最近N根收盘价做线性拟合
|
||||
N = min(80, len(df))
|
||||
sub = df.tail(N)
|
||||
y = sub['close'].values
|
||||
x = np.arange(len(y))
|
||||
denom = np.dot(x - x.mean(), x - x.mean())
|
||||
if denom != 0:
|
||||
m = float(np.dot(y - y.mean(), x - x.mean()) / denom)
|
||||
b = float(y.mean() - m * x.mean())
|
||||
else:
|
||||
m, b = 0.0, float(y[-1])
|
||||
|
||||
client_tz = timezone(timezone_name)
|
||||
|
||||
return jsonify({
|
||||
'symbol': symbol,
|
||||
'timeframe': timeframe,
|
||||
'timezone': timezone_name,
|
||||
'direction': direction,
|
||||
'stage': stage,
|
||||
'strength': float(round(strength, 2)),
|
||||
'kline_data': clean_dataframe_for_json(df)[['timestamp','open','high','low','close','volume','ema5','ema10','ema24','ema52']].to_dict('records'),
|
||||
'trend_line': {
|
||||
'offset': int(df.index[-N]),
|
||||
'slope': m,
|
||||
'intercept': b,
|
||||
'length': int(N)
|
||||
}
|
||||
})
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""主页"""
|
||||
|
||||
+497
-4
@@ -1057,6 +1057,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="trade-points-tab" data-bs-toggle="tab" data-bs-target="#trade-points" type="button" role="tab">买卖点</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="trend-filter-tab" data-bs-toggle="tab" data-bs-target="#trend-filter" type="button" role="tab">趋势筛选(币对)</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="stock-filter-tab" data-bs-toggle="tab" data-bs-target="#stock-filter" type="button" role="tab">股票筛选</button>
|
||||
</li>
|
||||
@@ -1177,6 +1180,125 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="trend-filter" role="tabpanel">
|
||||
<div class="container-fluid">
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">K线周期</label>
|
||||
<select id="trendTimeframe" class="form-select">
|
||||
<option value="1m">1m</option>
|
||||
<option value="5m">5m</option>
|
||||
<option value="15m" selected>15m</option>
|
||||
<option value="30m">30m</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="1d">1d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">方向</label>
|
||||
<select id="trendDirection" class="form-select">
|
||||
<option value="">全部</option>
|
||||
<option value="bull">多头</option>
|
||||
<option value="bear">空头</option>
|
||||
<option value="sideways">盘整</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">阶段</label>
|
||||
<select id="trendStage" class="form-select">
|
||||
<option value="">全部</option>
|
||||
<option value="early">初期</option>
|
||||
<option value="mid">中期</option>
|
||||
<option value="late">末期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">强度 (0-100)</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<input type="range" id="trendMinStrength" class="form-range me-2" min="0" max="100" value="30">
|
||||
<input type="number" id="trendMinStrengthNum" class="form-control" style="width:90px" min="0" max="100" value="30">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">自选币对(逗号分隔)</label>
|
||||
<input id="trendSymbols" class="form-control" placeholder="例如: BTC/USDT:USDT,ETH/USDT:USDT,可留空">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">开始时间</label>
|
||||
<input type="datetime-local" id="trendStart" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">结束时间</label>
|
||||
<input type="datetime-local" id="trendEnd" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" id="btnTrendFilter">开始筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container mb-3">
|
||||
<div id="trendFilterStatus" class="alert alert-info py-2" style="display:none;">
|
||||
<span class="loading-spinner"></span>
|
||||
<span class="ms-2">正在筛选,请稍候...</span>
|
||||
</div>
|
||||
<table id="trendFilterTable" class="display compact" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>交易对</th>
|
||||
<th>时间</th>
|
||||
<th>方向</th>
|
||||
<th>阶段</th>
|
||||
<th>强度</th>
|
||||
<th>收盘</th>
|
||||
<th>EMA5</th>
|
||||
<th>EMA10</th>
|
||||
<th>EMA24</th>
|
||||
<th>EMA52</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
趋势详情
|
||||
<span id="trendDetailStatus" class="text-muted ms-3" style="display:none;">
|
||||
<span class="loading-spinner"></span>
|
||||
<span class="ms-2">正在加载详情...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="trendChartContainer" style="height:420px;"></div>
|
||||
<div class="table-container mt-3">
|
||||
<table id="trendDetailTable" class="display compact" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>开</th>
|
||||
<th>高</th>
|
||||
<th>低</th>
|
||||
<th>收</th>
|
||||
<th>成交量</th>
|
||||
<th>EMA5</th>
|
||||
<th>EMA10</th>
|
||||
<th>EMA24</th>
|
||||
<th>EMA52</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="stock-filter" role="tabpanel">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
@@ -1259,6 +1381,377 @@
|
||||
<script>
|
||||
let currentData = null;
|
||||
const tables = {};
|
||||
// ======= 趋势筛选(币对) =======
|
||||
let trendTable = null;
|
||||
let trendDetailTable = null;
|
||||
let trendChart = null;
|
||||
|
||||
function initTrendTables() {
|
||||
if (!trendTable) {
|
||||
trendTable = $('#trendFilterTable').DataTable({
|
||||
paging: true,
|
||||
searching: false,
|
||||
info: true,
|
||||
order: [[4, 'desc']],
|
||||
});
|
||||
}
|
||||
if (!trendDetailTable) {
|
||||
trendDetailTable = $('#trendDetailTable').DataTable({
|
||||
paging: true,
|
||||
searching: false,
|
||||
info: true,
|
||||
order: [[0, 'desc']],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindTrendControls() {
|
||||
// 双向绑定强度滑块与数字框
|
||||
$('#trendMinStrength').on('input change', function(){
|
||||
$('#trendMinStrengthNum').val($(this).val());
|
||||
});
|
||||
$('#trendMinStrengthNum').on('input change', function(){
|
||||
let v = Math.max(0, Math.min(100, parseFloat($(this).val()||0)));
|
||||
$(this).val(v);
|
||||
$('#trendMinStrength').val(v);
|
||||
});
|
||||
|
||||
// 周期变化时,自动填充当前时间回溯300根K线的时间范围
|
||||
$('#trendTimeframe').on('change', function(){
|
||||
const tf = $(this).val();
|
||||
const tfToMs = {
|
||||
'1m': 60*1000, '5m': 5*60*1000, '15m': 15*60*1000, '30m': 30*60*1000,
|
||||
'1h': 60*60*1000, '4h': 4*60*60*1000, '1d': 24*60*60*1000
|
||||
};
|
||||
const step = tfToMs[tf] || (60*60*1000);
|
||||
const now = new Date();
|
||||
const endMs = now.getTime();
|
||||
const startMs = endMs - 300 * step;
|
||||
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
$('#trendEnd').val(toLocal(endMs));
|
||||
$('#trendStart').val(toLocal(startMs));
|
||||
});
|
||||
|
||||
$('#btnTrendFilter').on('click', async function(){
|
||||
await runTrendFilter();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTrendFilter() {
|
||||
initTrendTables();
|
||||
trendTable.clear().draw();
|
||||
|
||||
const timeframe = $('#trendTimeframe').val();
|
||||
const direction = $('#trendDirection').val();
|
||||
const stage = $('#trendStage').val();
|
||||
const minStrength = $('#trendMinStrength').val();
|
||||
const symbols = $('#trendSymbols').val();
|
||||
let start = $('#trendStart').val();
|
||||
let end = $('#trendEnd').val();
|
||||
|
||||
// 前端必须提供时间范围:若为空,自动以当前时间回溯300根
|
||||
if (!start || !end) {
|
||||
const tfToMs = {
|
||||
'1m': 60*1000, '5m': 5*60*1000, '15m': 15*60*1000, '30m': 30*60*1000,
|
||||
'1h': 60*60*1000, '4h': 4*60*60*1000, '1d': 24*60*60*1000
|
||||
};
|
||||
const step = tfToMs[timeframe] || (60*60*1000);
|
||||
const now = Date.now();
|
||||
const startMsAuto = now - 300 * step;
|
||||
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
if (!end) $('#trendEnd').val(toLocal(now));
|
||||
if (!start) $('#trendStart').val(toLocal(startMsAuto));
|
||||
start = $('#trendStart').val();
|
||||
end = $('#trendEnd').val();
|
||||
}
|
||||
|
||||
let startMs = start ? new Date(start).getTime() : '';
|
||||
let endMs = end ? new Date(end).getTime() : '';
|
||||
|
||||
const params = $.param({
|
||||
timeframe: timeframe,
|
||||
direction: direction || '',
|
||||
stage: stage || '',
|
||||
min_strength: minStrength,
|
||||
symbols: symbols || '',
|
||||
start_time: startMs || '',
|
||||
end_time: endMs || ''
|
||||
});
|
||||
|
||||
// 显示筛选状态
|
||||
$('#trendFilterStatus').show();
|
||||
try {
|
||||
const res = await $.getJSON(`/api/trend_filter?${params}`);
|
||||
// 初筛后端结果,再次用前端方向筛选(避免后端噪声)
|
||||
const dirVal = $('#trendDirection').val();
|
||||
const rows = (res.results || [])
|
||||
.filter(r => {
|
||||
if (!dirVal) return true;
|
||||
return r.direction === dirVal;
|
||||
})
|
||||
.map(r => [
|
||||
r.symbol,
|
||||
new Date(r.time).toLocaleString('zh-CN', { timeZone: $('#timezone').val() || 'Asia/Shanghai' }),
|
||||
r.direction === 'bull' ? '多头' : (r.direction === 'bear' ? '空头' : '盘整'),
|
||||
r.stage === 'early' ? '初期' : (r.stage === 'mid' ? '中期' : '末期'),
|
||||
r.strength,
|
||||
r.close,
|
||||
r.ema5,
|
||||
r.ema10,
|
||||
r.ema24,
|
||||
r.ema52,
|
||||
`<button class="btn btn-sm btn-outline-primary" data-symbol="${r.symbol}" data-timeframe="${timeframe}">查看</button>`
|
||||
]);
|
||||
trendTable.rows.add(rows).draw();
|
||||
|
||||
// 绑定查看按钮
|
||||
$('#trendFilterTable').off('click', 'button').on('click', 'button', function(){
|
||||
const sym = $(this).data('symbol');
|
||||
const tf = $(this).data('timeframe');
|
||||
loadTrendDetail(sym, tf, startMs, endMs);
|
||||
});
|
||||
|
||||
// 精细化阶段判定(前端基于明细重算)
|
||||
refineTrendStages(Array.from(new Set((res.results||[]).map(r => r.symbol))).slice(0, 20), timeframe, startMs, endMs);
|
||||
} catch (e) {
|
||||
alert('趋势筛选失败: ' + e);
|
||||
} finally {
|
||||
$('#trendFilterStatus').hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrendDetail(symbol, timeframe, startMs, endMs) {
|
||||
const params = $.param({
|
||||
symbol: symbol,
|
||||
timeframe: timeframe,
|
||||
start_time: startMs || '',
|
||||
end_time: endMs || '',
|
||||
timezone: $('#timezone').val() || 'Asia/Shanghai'
|
||||
});
|
||||
|
||||
// 显示详情加载状态
|
||||
$('#trendDetailStatus').show();
|
||||
try {
|
||||
const data = await $.getJSON(`/api/trend_detail?${params}`);
|
||||
// 填表
|
||||
trendDetailTable.clear();
|
||||
(data.kline_data || []).forEach(row => {
|
||||
trendDetailTable.row.add([
|
||||
new Date(row.timestamp).toLocaleString('zh-CN', { timeZone: data.timezone }),
|
||||
row.open, row.high, row.low, row.close, row.volume,
|
||||
row.ema5, row.ema10, row.ema24, row.ema52
|
||||
]);
|
||||
});
|
||||
trendDetailTable.draw();
|
||||
|
||||
// 画图
|
||||
drawTrendChart(data);
|
||||
} catch (e) {
|
||||
alert('加载趋势详情失败: ' + e);
|
||||
} finally {
|
||||
$('#trendDetailStatus').hide();
|
||||
}
|
||||
}
|
||||
|
||||
function drawTrendChart(data) {
|
||||
const container = document.getElementById('trendChartContainer');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const chart = LightweightCharts.createChart(container, {
|
||||
layout: { background: { color: '#ffffff' }, textColor: '#333' },
|
||||
rightPriceScale: { visible: true },
|
||||
timeScale: { timeVisible: true, secondsVisible: false },
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
grid: { vertLines: { color: '#eee' }, horzLines: { color: '#eee' } },
|
||||
autoSize: true
|
||||
});
|
||||
trendChart = chart;
|
||||
|
||||
const candle = chart.addCandlestickSeries();
|
||||
// 关闭均线的价格线与最后值标签,仅保留K线的当前价格虚线
|
||||
const ema5 = chart.addLineSeries({ color: '#ff0000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema10 = chart.addLineSeries({ color: '#2962FF', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema24 = chart.addLineSeries({ color: '#008000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema52 = chart.addLineSeries({ color: '#800080', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
|
||||
const k = (data.kline_data || []).map(r => ({
|
||||
time: Math.floor(r.timestamp / 1000),
|
||||
open: Number(r.open), high: Number(r.high), low: Number(r.low), close: Number(r.close)
|
||||
}));
|
||||
candle.setData(k);
|
||||
|
||||
// 前端过滤均线前导缺失/无效值,避免绘制为0
|
||||
const sanitizeMA = (field) => {
|
||||
const rows = data.kline_data || [];
|
||||
const out = [];
|
||||
let started = false;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const raw = r[field];
|
||||
const v = Number(raw);
|
||||
const valid = Number.isFinite(v) && v > 0;
|
||||
if (!started) {
|
||||
if (!valid) continue;
|
||||
started = true;
|
||||
}
|
||||
if (!valid) continue;
|
||||
out.push({ time: Math.floor(r.timestamp / 1000), value: v });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
ema5.setData(sanitizeMA('ema5'));
|
||||
ema10.setData(sanitizeMA('ema10'));
|
||||
ema24.setData(sanitizeMA('ema24'));
|
||||
ema52.setData(sanitizeMA('ema52'));
|
||||
|
||||
// 趋势线(使用返回的拟合参数)
|
||||
const trend = data.trend_line || null;
|
||||
if (trend && k.length > 1) {
|
||||
const L = Math.min(trend.length, k.length);
|
||||
const startIdx = k.length - L;
|
||||
const lineData = [];
|
||||
for (let i = 0; i < L; i++) {
|
||||
const y = trend.slope * i + trend.intercept;
|
||||
const point = { time: k[startIdx + i].time, value: y };
|
||||
lineData.push(point);
|
||||
}
|
||||
const trendSeries = chart.addLineSeries({ color: '#ffa500', lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false });
|
||||
trendSeries.setData(lineData);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 前端精细化阶段判定 =====
|
||||
function computeEMA(arr, period) {
|
||||
const k = 2 / (period + 1);
|
||||
const out = [];
|
||||
let emaPrev = null;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const price = arr[i];
|
||||
if (price == null || !isFinite(price)) { out.push(null); continue; }
|
||||
if (emaPrev == null) {
|
||||
// 取首个可用SMA种子
|
||||
const start = Math.max(0, i - period + 1);
|
||||
const window = arr.slice(start, i + 1).filter(v => v != null && isFinite(v));
|
||||
const sma = window.length ? window.reduce((a,b)=>a+b,0)/window.length : price;
|
||||
emaPrev = sma;
|
||||
}
|
||||
const ema = price * k + emaPrev * (1 - k);
|
||||
out.push(ema);
|
||||
emaPrev = ema;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeMACDSeries(close) {
|
||||
const ema12 = computeEMA(close, 12);
|
||||
const ema26 = computeEMA(close, 26);
|
||||
const macd = close.map((_, i) => (ema12[i] != null && ema26[i] != null) ? (ema12[i] - ema26[i]) : null);
|
||||
const signal = computeEMA(macd.map(v => v ?? null), 9);
|
||||
const hist = macd.map((v, i) => (v != null && signal[i] != null) ? (v - signal[i]) : null);
|
||||
return { macd, signal, hist };
|
||||
}
|
||||
|
||||
function slope(series, win) {
|
||||
const n = series.length;
|
||||
const k = Math.min(win, n);
|
||||
if (k < 3) return 0;
|
||||
const y = series.slice(n - k).filter(v => v != null && isFinite(v));
|
||||
if (y.length < 3) return 0;
|
||||
const x = [...Array(y.length).keys()];
|
||||
const xm = x.reduce((a,b)=>a+b,0)/x.length;
|
||||
const ym = y.reduce((a,b)=>a+b,0)/y.length;
|
||||
let num = 0, den = 0;
|
||||
for (let i=0;i<x.length;i++){ num += (x[i]-xm)*(y[i]-ym); den += (x[i]-xm)*(x[i]-xm); }
|
||||
return den ? num/den : 0;
|
||||
}
|
||||
|
||||
function classifyStageFrontend(kline, directionHint) {
|
||||
const close = kline.map(r => Number(r.close));
|
||||
const ema24 = computeEMA(close, 24);
|
||||
const ema52 = computeEMA(close, 52);
|
||||
const last = close[close.length-1];
|
||||
const e24 = ema24[ema24.length-1];
|
||||
const e52 = ema52[ema52.length-1];
|
||||
const s24 = slope(ema24, 20);
|
||||
const s52 = slope(ema52, 30);
|
||||
const dist52 = (e52 && isFinite(e52)) ? (last - e52)/e52 : 0;
|
||||
const { hist } = computeMACDSeries(close);
|
||||
const recent = hist.slice(-9).filter(v => v != null);
|
||||
const earlier = hist.slice(-18, -9).filter(v => v != null);
|
||||
const growth = (recent.length && earlier.length) ? (avgAbs(recent) - avgAbs(earlier)) : 0;
|
||||
|
||||
function avgAbs(arr){ return arr.reduce((a,b)=>a+Math.abs(b),0)/arr.length; }
|
||||
|
||||
let direction = directionHint;
|
||||
if (!direction) {
|
||||
if (e24 > e52 && s24 > 0 && s52 > 0) direction = 'bull';
|
||||
else if (e24 < e52 && s24 < 0 && s52 < 0) direction = 'bear';
|
||||
else direction = 'sideways';
|
||||
}
|
||||
|
||||
let stage = 'early';
|
||||
const ad = Math.abs(dist52);
|
||||
if (direction === 'bull') {
|
||||
if (ad < 0.03 && growth > 0) stage = 'early';
|
||||
else if (ad < 0.10 && (growth >= 0 || s24 > 0)) stage = 'mid';
|
||||
else stage = 'late';
|
||||
} else if (direction === 'bear') {
|
||||
if (ad < 0.03 && growth > 0) stage = 'early';
|
||||
else if (ad < 0.10 && (growth >= 0 || s24 < 0)) stage = 'mid';
|
||||
else stage = 'late';
|
||||
} else {
|
||||
stage = 'early';
|
||||
}
|
||||
return { direction, stage };
|
||||
}
|
||||
|
||||
async function refineTrendStages(symbols, timeframe, startMs, endMs) {
|
||||
if (!symbols || symbols.length === 0) return;
|
||||
// 在表头上方提示
|
||||
const info = $('<div class="text-muted mb-2" id="refineInfo">正在优化阶段判定...</div>');
|
||||
$('#trendFilterTable').before(info);
|
||||
const tz = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const selectedDir = $('#trendDirection').val(); // bull/bear/sideways/''
|
||||
for (const sym of symbols) {
|
||||
try {
|
||||
const params = $.param({ symbol: sym, timeframe, start_time: startMs, end_time: endMs, timezone: tz });
|
||||
const data = await $.getJSON(`/api/trend_detail?${params}`);
|
||||
const { direction, stage } = classifyStageFrontend(data.kline_data || [], null);
|
||||
// 若与选择的方向不一致,则在前端移除该行,避免“选择多头仍出现空头/盘整”
|
||||
if (selectedDir && direction !== selectedDir) {
|
||||
if (trendTable) {
|
||||
trendTable.rows().every(function(){
|
||||
const rowData = this.data();
|
||||
if (rowData && rowData[0] === sym) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
trendTable.draw(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 否则更新该行方向与阶段展示
|
||||
$('#trendFilterTable tbody tr').each(function(){
|
||||
const tds = $(this).find('td');
|
||||
if (tds.eq(0).text() === sym) {
|
||||
tds.eq(2).text(direction === 'bull' ? '多头' : (direction === 'bear' ? '空头' : '盘整'));
|
||||
tds.eq(3).text(stage === 'early' ? '初期' : stage === 'mid' ? '中期' : '末期');
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
info.remove();
|
||||
}
|
||||
|
||||
// 页面初始化时绑定控件
|
||||
$(function(){
|
||||
initTrendTables();
|
||||
bindTrendControls();
|
||||
});
|
||||
let tvWidget = {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
@@ -4314,7 +4807,7 @@
|
||||
if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示
|
||||
displayText = fx.fx_strength >= 0.8 ? '' : '' // 0.8以上显示点,0.8以下不显示文本
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 添加标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
@@ -4378,7 +4871,7 @@
|
||||
if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示
|
||||
displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.8以上显示点,0.8以下不显示文本
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("12", "").replace("13", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 添加标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
@@ -4462,7 +4955,7 @@
|
||||
if (fx.fx_strength < 1.0){ // 调整小周期阈值
|
||||
displayText = fx.fx_strength >= 0.6 ? '' : '' // 0.6以上显示点
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 小周期分型标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
@@ -4518,7 +5011,7 @@
|
||||
if (fx.fx_strength < 2.0){ // 调整小周期阈值
|
||||
displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.6以上显示点
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("13", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 小周期KLU分型标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
|
||||
Reference in New Issue
Block a user