添加k线动能理论

This commit is contained in:
jackyu66git
2025-08-14 02:29:27 +08:00
parent 285f62f1ab
commit 15a3df55db
16 changed files with 1400 additions and 170 deletions
+245 -3
View File
@@ -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
View File
@@ -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,