2968 lines
148 KiB
JavaScript
2968 lines
148 KiB
JavaScript
const { createApp } = Vue;
|
||
|
||
const ALERT_CACHE_VERSION = 5;
|
||
|
||
// 配置axios携带credentials(支持跨域session)
|
||
axios.defaults.withCredentials = true;
|
||
|
||
createApp({
|
||
data() {
|
||
// 动态计算上一年1月1日
|
||
const lastYear = new Date().getFullYear() - 1;
|
||
const defaultStartDate = `${lastYear}-01-01`;
|
||
|
||
return {
|
||
// 分析页面
|
||
stockCode: '',
|
||
startDate: defaultStartDate, // 动态设置为上一年1月1日
|
||
endDate: '',
|
||
loading: false,
|
||
error: null,
|
||
result: null,
|
||
|
||
theoryExpanded: false,
|
||
periods: ['当日', '未来1日', '未来2日', '未来3日', '未来4日', '未来5日'],
|
||
superChart: null,
|
||
mainChart: null,
|
||
latestData: {},
|
||
expandedSections: {
|
||
charts: false,
|
||
table: false,
|
||
summary: false
|
||
},
|
||
searchHistory: [],
|
||
showHistory: false,
|
||
clockTick: Date.now(),
|
||
|
||
// 全局提示模态框
|
||
toastMessage: '',
|
||
toastType: 'info', // info, success, error, warning
|
||
toastVisible: false,
|
||
|
||
// 确认对话框
|
||
confirmVisible: false,
|
||
confirmMessage: '',
|
||
confirmCallback: null,
|
||
|
||
// 用户登录
|
||
currentUser: null,
|
||
checkingAuth: true, // 正在检查登录状态
|
||
showLoginModal: false,
|
||
isRegister: false,
|
||
loginForm: { email: '', password: '' },
|
||
loginError: '',
|
||
loginLoading: false,
|
||
|
||
// 修改密码
|
||
showPasswordModal: false,
|
||
passwordForm: { oldPassword: '', newPassword: '', confirmPassword: '' },
|
||
passwordLoading: false,
|
||
passwordError: '',
|
||
passwordSuccess: '',
|
||
|
||
// 导航
|
||
activeTab: 'alerts',
|
||
|
||
// 提醒页面
|
||
alertsLoading: false,
|
||
alertsProgress: { current: 0, total: 0 }, // 分析进度
|
||
stockAlerts: [], // 所有股票的分析结果
|
||
alertSubTab: 'holding', // 'holding' 或 'watching'
|
||
showAddWatch: false, // 显示添加关注输入框
|
||
newWatchCode: '', // 新关注股票代码
|
||
|
||
// 今日信号
|
||
todaySignalStock: '',
|
||
todaySignalData: null,
|
||
|
||
// 技术信号
|
||
techSignalCode: '',
|
||
techSignalResult: null,
|
||
techBatchResults: [],
|
||
techLoading: false,
|
||
// 全量扫描
|
||
fullScanResults: [],
|
||
fullScanStatus: null,
|
||
fullScanLoading: false,
|
||
fullScanPage: 1,
|
||
fullScanTotalPages: 1,
|
||
fullScanPerPage: 5, // 每页显示5只股票
|
||
fullScanFilter: 'all',
|
||
fullScanFilterTypes: [],
|
||
fullScanFilterRecommend: '买入', // 扫描结果打开时缺省显示买入列表;all|买入|加仓|卖出|持有|关注|观察|观望
|
||
scanSummaryCollapsed: false,
|
||
scanResultsCollapsed: false, // 结果列表可收起
|
||
fullScanSignalDist: [],
|
||
|
||
// 找牛股(保留兼容)
|
||
bullStocksData: null,
|
||
bullStocksLoading: false,
|
||
bullActiveStage: 2,
|
||
|
||
// 个股深析
|
||
deepCode: '',
|
||
deepReport: null,
|
||
deepLoading: false,
|
||
|
||
// 模型页子tab
|
||
modelSubTab: 'system',
|
||
|
||
// 买入分析
|
||
buyAnalysisList: [],
|
||
buyAnalysisLoading: false,
|
||
buyAnalysisProgress: 0,
|
||
buyAnalysisTotal: 0,
|
||
|
||
// 交易记录页面
|
||
trades: [],
|
||
tradeStats: null,
|
||
availableCash: 0, // 可用资金
|
||
editingCash: false, // 是否在编辑可用资金
|
||
cashInputValue: '', // 编辑时的输入值
|
||
holdingPositions: {}, // 持仓信息:{ 股票代码: { quantity, cost, currentPrice, name } }
|
||
priceRefreshing: false, // 刷新价格状态
|
||
stopLossAlerts: [], // 止损预警列表
|
||
stopLossAlertsExpanded: true, // 止损预警区域是否展开
|
||
expandedTradeGroups: {}, // 展开的交易组:{ 股票代码: true/false }
|
||
tradeViewType: 'holding', // 交易视图类型:'holding' 或 'cleared'
|
||
showFundamentalModal: false, // 基本面弹窗
|
||
fundamentalLoading: false, // 加载状态
|
||
fundamentalData: null, // 基本面数据
|
||
aiAnalyzing: false, // AI分析中
|
||
aiAnalysisResult: '', // AI分析结果(基本面弹窗用)
|
||
aiReasoningResult: '', // AI思考过程(基本面弹窗用)
|
||
aiReasoningExpanded: false, // AI思考过程是否展开
|
||
klineChart: null, // K线图实例
|
||
klinePeriod: 'monthly', // K线周期:weekly, monthly, quarterly, yearly
|
||
klineLoading: false, // K线加载状态
|
||
showTradeForm: false,
|
||
editingTradeId: null,
|
||
selectedStockFromHistory: '',
|
||
selectedStockSignal: { type: '', icon: '', title: '', description: '' },
|
||
analysisCache: {}, // 缓存每个股票的分析结果
|
||
|
||
// 分析页子导航
|
||
analysisSubTab: 'scan', // 'scan' 或 'sim'
|
||
|
||
// 模拟交易相关
|
||
simStats: {}, // 模拟交易统计
|
||
simPositions: [], // 模拟持仓
|
||
simTrades: [], // 模拟交易记录
|
||
simAutoTrading: false, // 自动交易执行中
|
||
simPriceRefreshing: false, // 模拟持仓刷新现价中
|
||
simRuleExpanded: false, // 交易规则是否展开
|
||
|
||
// 智能交易引擎
|
||
smartAlgoConfig: null, // 当前算法配置
|
||
smartAlgoIsDefault: true, // 是否默认配置
|
||
smartTemplates: [], // 算法模板列表
|
||
smartEngineStatus: null, // 引擎状态
|
||
smartSignals: [], // 信号日志
|
||
smartPositionMeta: [], // 持仓元数据
|
||
smartConfigExpanded: false, // 算法配置展开
|
||
smartSignalExpanded: false, // 信号日志展开
|
||
smartConfigEditing: false, // 正在编辑配置
|
||
todayTradesExpanded: false, // 今日交易展开
|
||
simPositionsExpanded: false, // 当前持仓展开
|
||
simTradesExpanded: false, // 交易记录展开
|
||
|
||
// 智能执行结果模态框
|
||
smartResultVisible: false,
|
||
smartResultData: null, // { algo, type, title, reasons, results, fees, summary }
|
||
compareExpanded: false, // 模拟vs实盘对比展开
|
||
|
||
newTrade: {
|
||
stock_code: '',
|
||
stock_name: '',
|
||
trade_type: 'buy',
|
||
price: '',
|
||
quantity: '',
|
||
trade_date: '',
|
||
reason: '',
|
||
result: 'pending',
|
||
profit_amount: '',
|
||
stop_loss_price: '',
|
||
notes: ''
|
||
}
|
||
};
|
||
},
|
||
computed: {
|
||
// 今日日期
|
||
todayDate() {
|
||
void this.clockTick;
|
||
const now = new Date();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
return `${month}-${day}`;
|
||
},
|
||
// 市场状态:开市/闭市(clockTick驱动自动刷新)
|
||
marketStatus() {
|
||
void this.clockTick;
|
||
const now = new Date();
|
||
const day = now.getDay(); // 0=周日, 1-5=周一到周五, 6=周六
|
||
const hour = now.getHours();
|
||
const minute = now.getMinutes();
|
||
const time = hour * 100 + minute; // 如 1430 = 14:30
|
||
|
||
// 周末闭市
|
||
if (day === 0 || day === 6) {
|
||
return { isOpen: false, text: '闭市' };
|
||
}
|
||
|
||
// 交易时间:9:30-11:30, 13:00-15:00
|
||
if ((time >= 930 && time <= 1130) || (time >= 1300 && time <= 1500)) {
|
||
return { isOpen: true, text: '开市' };
|
||
}
|
||
|
||
return { isOpen: false, text: '闭市' };
|
||
},
|
||
// 数据最新日期(从API返回的stocks中获取)
|
||
dataLatestDate() {
|
||
if (this.stocks && this.stocks.length > 0) {
|
||
const dates = this.stocks.map(s => s.lastUpdateTime || s.date).filter(d => d);
|
||
if (dates.length > 0) {
|
||
const latestDate = dates.sort().reverse()[0];
|
||
// 取前10个字符作为日期,格式化为 MM-DD
|
||
const dateStr = latestDate.substring(0, 10);
|
||
return dateStr.substring(5);
|
||
}
|
||
}
|
||
return '';
|
||
},
|
||
// 买入信号列表(仅关注股票,按推荐率排序)
|
||
buyAlerts() {
|
||
const watchCodes = this.searchHistory.map(h => h.code);
|
||
return this.stockAlerts
|
||
.filter(a => a.signalType === 'buy' && !this.holdingStocks.includes(a.code) && watchCodes.includes(a.code))
|
||
.sort((a, b) => (b.recommendRate || 0) - (a.recommendRate || 0));
|
||
},
|
||
// 卖出信号列表(仅关注股票,按推荐率排序)
|
||
sellAlerts() {
|
||
const watchCodes = this.searchHistory.map(h => h.code);
|
||
return this.stockAlerts
|
||
.filter(a => a.signalType === 'sell' && !this.holdingStocks.includes(a.code) && watchCodes.includes(a.code))
|
||
.sort((a, b) => (b.recommendRate || 0) - (a.recommendRate || 0));
|
||
},
|
||
// 观望列表(仅关注股票)
|
||
watchAlerts() {
|
||
const watchCodes = this.searchHistory.map(h => h.code);
|
||
return this.stockAlerts.filter(a => a.signalType === 'watch' && !this.holdingStocks.includes(a.code) && watchCodes.includes(a.code));
|
||
},
|
||
// 有效提醒数量(买入+卖出)
|
||
alertCount() {
|
||
return this.buyAlerts.length + this.sellAlerts.length;
|
||
},
|
||
// 今日交易记录
|
||
todayTrades() {
|
||
const today = new Date().toISOString().substring(0, 10);
|
||
return (this.simTrades || []).filter(t => t.trade_date === today);
|
||
},
|
||
// 全景扫描结果按「推荐」筛选(按具体内容:全部/买入/加仓/卖出/持有/关注/观察/观望,并显示数量)
|
||
fullScanResultsFiltered() {
|
||
const r = this.fullScanFilterRecommend;
|
||
if (r === 'all') return this.fullScanResults;
|
||
return this.fullScanResults.filter(item => {
|
||
const text = (item.recommend_text != null) ? item.recommend_text : this.getScanRecommend(item).text;
|
||
return text === r;
|
||
});
|
||
},
|
||
// 推荐类型列表(含数量),顺序固定
|
||
fullScanRecommendOptions() {
|
||
const counts = this.fullScanStatus?.recommend_counts || {};
|
||
const hints = { '持有': '仅持仓且触达真龙时显示,建议继续持有' };
|
||
const order = ['买入', '加仓', '卖出', '持有', '关注', '观察', '观望'];
|
||
return order.map(text => ({ text, count: counts[text] || 0, hint: hints[text] }));
|
||
},
|
||
|
||
// 持有的股票代码(从交易记录中获取买入但未卖出的)
|
||
holdingStocks() {
|
||
const buyStocks = {};
|
||
const sellStocks = {};
|
||
|
||
// 统计每个股票的买入和卖出数量
|
||
this.trades.forEach(trade => {
|
||
if (trade.trade_type === 'buy') {
|
||
buyStocks[trade.stock_code] = (buyStocks[trade.stock_code] || 0) + parseInt(trade.quantity || 0);
|
||
} else if (trade.trade_type === 'sell') {
|
||
sellStocks[trade.stock_code] = (sellStocks[trade.stock_code] || 0) + parseInt(trade.quantity || 0);
|
||
}
|
||
});
|
||
|
||
// 持有 = 买入数量 > 卖出数量
|
||
return Object.keys(buyStocks).filter(code =>
|
||
(buyStocks[code] || 0) > (sellStocks[code] || 0)
|
||
);
|
||
},
|
||
|
||
// 关注的股票(在历史记录中但不在持有列表中)
|
||
watchingStocks() {
|
||
return this.searchHistory.filter(item =>
|
||
!this.holdingStocks.includes(item.code)
|
||
);
|
||
},
|
||
|
||
// 所有可选股票(关注列表 + 持有股票,用于交易记录表单)
|
||
allStocksForTrade() {
|
||
const allStocks = [...this.searchHistory];
|
||
|
||
// 添加持有的股票(如果不在关注列表中)
|
||
this.holdingStocks.forEach(code => {
|
||
if (!allStocks.some(item => item.code === code)) {
|
||
const trade = this.trades.find(t => t.stock_code === code);
|
||
allStocks.push({
|
||
code: code,
|
||
name: trade?.stock_name || `股票${code}`,
|
||
isHolding: true // 标记为持有股票
|
||
});
|
||
}
|
||
});
|
||
|
||
// 给关注列表中已持有的股票也加上标记
|
||
return allStocks.map(item => ({
|
||
...item,
|
||
isHolding: this.holdingStocks.includes(item.code)
|
||
}));
|
||
},
|
||
|
||
// 持有股票的提醒
|
||
holdingAlerts() {
|
||
return this.stockAlerts
|
||
.filter(a => this.holdingStocks.includes(a.code))
|
||
.sort((a, b) => (b.recommendRate || 0) - (a.recommendRate || 0));
|
||
},
|
||
|
||
// 按股票分组、按时间倒序排序的交易记录
|
||
groupedTrades() {
|
||
if (!this.trades || this.trades.length === 0) return [];
|
||
|
||
// 按股票代码分组
|
||
const groups = {};
|
||
this.trades.forEach(trade => {
|
||
const code = trade.stock_code;
|
||
if (!groups[code]) {
|
||
groups[code] = {
|
||
code: code,
|
||
name: trade.stock_name || code,
|
||
trades: [],
|
||
latestDate: trade.trade_date,
|
||
realizedProfit: 0 // 已实现盈亏
|
||
};
|
||
}
|
||
groups[code].trades.push(trade);
|
||
// 记录该组最新的交易日期
|
||
if (trade.trade_date > groups[code].latestDate) {
|
||
groups[code].latestDate = trade.trade_date;
|
||
}
|
||
});
|
||
|
||
// 计算每个组的已实现盈亏(使用平均成本法)
|
||
Object.values(groups).forEach(group => {
|
||
// 按交易日期和创建时间排序(从早到晚)
|
||
const sortedTrades = [...group.trades].sort((a, b) => {
|
||
if (a.trade_date !== b.trade_date) {
|
||
return a.trade_date.localeCompare(b.trade_date);
|
||
}
|
||
return (a.created_at || '').localeCompare(b.created_at || '');
|
||
});
|
||
|
||
let holdingQty = 0;
|
||
let holdingCost = 0;
|
||
let realizedProfit = 0;
|
||
|
||
sortedTrades.forEach(trade => {
|
||
const qty = parseInt(trade.quantity) || 0;
|
||
const price = parseFloat(trade.price) || 0;
|
||
|
||
if (trade.trade_type === 'buy') {
|
||
holdingCost += qty * price;
|
||
holdingQty += qty;
|
||
} else if (trade.trade_type === 'sell' && holdingQty > 0) {
|
||
// 计算平均成本
|
||
const avgCost = holdingCost / holdingQty;
|
||
// 已实现盈亏 = 卖出价 - 平均成本) × 卖出数量
|
||
realizedProfit += (price - avgCost) * qty;
|
||
// 更新持仓
|
||
holdingCost -= avgCost * qty;
|
||
holdingQty -= qty;
|
||
}
|
||
});
|
||
|
||
group.realizedProfit = realizedProfit;
|
||
});
|
||
|
||
// 每组内按时间倒序排序(显示用)
|
||
Object.values(groups).forEach(group => {
|
||
group.trades.sort((a, b) => {
|
||
// 先按日期倒序
|
||
if (b.trade_date !== a.trade_date) {
|
||
return b.trade_date.localeCompare(a.trade_date);
|
||
}
|
||
// 同日期按创建时间倒序
|
||
return (b.created_at || '').localeCompare(a.created_at || '');
|
||
});
|
||
});
|
||
|
||
// 组与组之间排序:先按是否持有(持有在前),再按最新交易日期倒序
|
||
const holdingPositions = this.holdingPositions || {};
|
||
return Object.values(groups).sort((a, b) => {
|
||
const aHolding = (holdingPositions[a.code]?.quantity || 0) > 0;
|
||
const bHolding = (holdingPositions[b.code]?.quantity || 0) > 0;
|
||
// 持有的排在前面
|
||
if (aHolding !== bHolding) {
|
||
return bHolding ? 1 : -1;
|
||
}
|
||
// 同类型按日期倒序
|
||
return b.latestDate.localeCompare(a.latestDate);
|
||
});
|
||
},
|
||
|
||
// 根据视图类型过滤交易分组
|
||
filteredGroupedTrades() {
|
||
if (!this.groupedTrades) return [];
|
||
const holdingPositions = this.holdingPositions || {};
|
||
|
||
if (this.tradeViewType === 'holding') {
|
||
// 持有中:显示当前持有股票数量 > 0 的
|
||
return this.groupedTrades.filter(group =>
|
||
(holdingPositions[group.code]?.quantity || 0) > 0
|
||
);
|
||
} else {
|
||
// 已清仓:显示当前持有股票数量 = 0 的
|
||
return this.groupedTrades.filter(group =>
|
||
(holdingPositions[group.code]?.quantity || 0) === 0
|
||
);
|
||
}
|
||
},
|
||
|
||
// 持有股票数量
|
||
holdingGroupCount() {
|
||
if (!this.groupedTrades) return 0;
|
||
const holdingPositions = this.holdingPositions || {};
|
||
return this.groupedTrades.filter(group =>
|
||
(holdingPositions[group.code]?.quantity || 0) > 0
|
||
).length;
|
||
},
|
||
|
||
// 清仓股票数量
|
||
clearedGroupCount() {
|
||
if (!this.groupedTrades) return 0;
|
||
const holdingPositions = this.holdingPositions || {};
|
||
return this.groupedTrades.filter(group =>
|
||
(holdingPositions[group.code]?.quantity || 0) === 0
|
||
).length;
|
||
},
|
||
|
||
tradingSignal() {
|
||
if (!this.result) return { type: '', icon: '', title: '', description: '' };
|
||
|
||
const pricePos = this.latestData.pricePosition || 50;
|
||
const superRatio = this.latestData.superRatio || 0;
|
||
const mainRatio = this.latestData.mainRatio || 0;
|
||
|
||
// 判断买卖信号
|
||
const isLow = pricePos <= 50;
|
||
const isHigh = pricePos > 50;
|
||
const isSuperInflow = superRatio >= 2;
|
||
const isSuperOutflow = superRatio <= -2;
|
||
const isMainInflow = mainRatio >= 2;
|
||
const isMainOutflow = mainRatio <= -2;
|
||
|
||
// 买入信号:低位 + 资金流入
|
||
if (isLow && (isSuperInflow || isMainInflow)) {
|
||
return {
|
||
type: 'buy',
|
||
icon: '✅',
|
||
title: '买入信号',
|
||
description: `价格处于${pricePos <= 30 ? '低位' : '中低位'}(${pricePos.toFixed(1)}%),${isSuperInflow ? '超大单' : '主力'}大额流入,建议买入,仓位30-50%,止损-5%`
|
||
};
|
||
}
|
||
|
||
// 卖出信号:高位 + 资金流出
|
||
if (isHigh && (isSuperOutflow || isMainOutflow)) {
|
||
return {
|
||
type: 'sell',
|
||
icon: '🔴',
|
||
title: '卖出信号',
|
||
description: `价格处于${pricePos >= 70 ? '高位' : '中高位'}(${pricePos.toFixed(1)}%),${isSuperOutflow ? '超大单' : '主力'}大额流出,建议卖出或减仓,及时锁定利润`
|
||
};
|
||
}
|
||
|
||
// 观望信号
|
||
let hint = '';
|
||
if (isLow) {
|
||
hint = '价格在低位,可关注资金流入信号后买入';
|
||
} else if (isHigh) {
|
||
hint = '价格在高位,可关注资金流出信号后卖出';
|
||
} else {
|
||
hint = '当前无明确买卖信号,建议继续观察';
|
||
}
|
||
|
||
return {
|
||
type: '',
|
||
icon: '⏸️',
|
||
title: '观望信号',
|
||
description: hint
|
||
};
|
||
}
|
||
},
|
||
async mounted() {
|
||
setInterval(() => { this.clockTick = Date.now(); }, 30000);
|
||
|
||
const today = new Date();
|
||
this.endDate = today.toISOString().split('T')[0];
|
||
|
||
// 检查当前用户登录状态
|
||
await this.checkCurrentUser();
|
||
|
||
// 未登录时不加载数据
|
||
if (!this.currentUser) {
|
||
return;
|
||
}
|
||
|
||
// 已登录,加载数据
|
||
this.alertsLoading = true;
|
||
this.alertsProgress = { current: 0, total: 0 };
|
||
|
||
// 加载关注列表(从服务器)
|
||
await this.loadHistory();
|
||
|
||
// 先加载交易记录(用于计算持有股票)
|
||
await this.loadTrades();
|
||
|
||
// 自动分析关注的股票
|
||
this.$nextTick(() => {
|
||
this.refreshAlerts();
|
||
});
|
||
},
|
||
watch: {},
|
||
methods: {
|
||
// ========== 用户认证 ==========
|
||
async checkCurrentUser() {
|
||
this.checkingAuth = true;
|
||
try {
|
||
const response = await axios.get('/api/me');
|
||
if (response.data.success && response.data.user) {
|
||
this.currentUser = response.data.user;
|
||
console.debug('已登录:', this.currentUser.username);
|
||
}
|
||
} catch (e) {
|
||
console.debug('未登录');
|
||
} finally {
|
||
this.checkingAuth = false;
|
||
}
|
||
},
|
||
|
||
async handleLogin() {
|
||
if (this.loginLoading) return;
|
||
|
||
const { email, password } = this.loginForm;
|
||
if (!email || !password) {
|
||
this.loginError = '请输入邮箱和密码';
|
||
return;
|
||
}
|
||
|
||
// 验证邮箱格式
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
if (!emailRegex.test(email)) {
|
||
this.loginError = '请输入有效的邮箱地址';
|
||
return;
|
||
}
|
||
|
||
if (this.isRegister && password.length < 6) {
|
||
this.loginError = '密码至少6位';
|
||
return;
|
||
}
|
||
|
||
this.loginError = '';
|
||
this.loginLoading = true;
|
||
|
||
try {
|
||
const url = this.isRegister ? '/api/register' : '/api/login';
|
||
const response = await axios.post(url, { email, password });
|
||
|
||
if (response.data.success) {
|
||
this.currentUser = response.data.user;
|
||
this.showLoginModal = false;
|
||
this.loginForm = { email: '', password: '' };
|
||
this.showToast(this.isRegister ? '注册成功' : '登录成功', 'success');
|
||
|
||
// 重新加载数据
|
||
await this.loadHistory();
|
||
await this.loadTrades();
|
||
this.refreshAlerts();
|
||
} else {
|
||
this.loginError = response.data.error || '操作失败';
|
||
}
|
||
} catch (e) {
|
||
this.loginError = e.response?.data?.error || '网络错误';
|
||
} finally {
|
||
this.loginLoading = false;
|
||
}
|
||
},
|
||
|
||
async handleChangePassword() {
|
||
if (this.passwordLoading) return;
|
||
|
||
const { oldPassword, newPassword, confirmPassword } = this.passwordForm;
|
||
|
||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||
this.passwordError = '请填写所有字段';
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 6) {
|
||
this.passwordError = '新密码至少6位';
|
||
return;
|
||
}
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
this.passwordError = '两次输入的新密码不一致';
|
||
return;
|
||
}
|
||
|
||
this.passwordError = '';
|
||
this.passwordSuccess = '';
|
||
this.passwordLoading = true;
|
||
|
||
try {
|
||
const response = await axios.post('/api/change_password', {
|
||
old_password: oldPassword,
|
||
new_password: newPassword
|
||
});
|
||
|
||
if (response.data.success) {
|
||
this.passwordSuccess = '密码修改成功';
|
||
this.passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' };
|
||
setTimeout(() => {
|
||
this.showPasswordModal = false;
|
||
this.passwordSuccess = '';
|
||
}, 1500);
|
||
} else {
|
||
this.passwordError = response.data.error || '修改失败';
|
||
}
|
||
} catch (e) {
|
||
this.passwordError = e.response?.data?.error || '网络错误';
|
||
} finally {
|
||
this.passwordLoading = false;
|
||
}
|
||
},
|
||
|
||
handleLogout() {
|
||
this.showConfirm('确定要退出登录吗?', async () => {
|
||
try {
|
||
await axios.post('/api/logout');
|
||
this.currentUser = null;
|
||
|
||
// 清空用户数据
|
||
this.searchHistory = [];
|
||
this.trades = [];
|
||
this.stockAlerts = [];
|
||
|
||
this.showToast('已退出登录', 'info');
|
||
} catch (e) {
|
||
console.error('退出失败', e);
|
||
}
|
||
});
|
||
},
|
||
|
||
// 显示提示信息(替代alert)
|
||
showToast(message, type = 'info', duration = 3000) {
|
||
this.toastMessage = message;
|
||
this.toastType = type;
|
||
this.toastVisible = true;
|
||
|
||
// 自动关闭
|
||
setTimeout(() => {
|
||
this.toastVisible = false;
|
||
}, duration);
|
||
},
|
||
|
||
// 显示确认对话框(替代confirm)
|
||
showConfirm(message, callback) {
|
||
this.confirmMessage = message;
|
||
this.confirmCallback = callback;
|
||
this.confirmVisible = true;
|
||
},
|
||
|
||
// 确认对话框 - 确定
|
||
handleConfirmOk() {
|
||
this.confirmVisible = false;
|
||
if (this.confirmCallback) {
|
||
this.confirmCallback();
|
||
}
|
||
},
|
||
|
||
// 确认对话框 - 取消
|
||
handleConfirmCancel() {
|
||
this.confirmVisible = false;
|
||
this.confirmCallback = null;
|
||
},
|
||
|
||
// 格式化金额,添加千分位
|
||
formatMoney(amount, decimals = 2) {
|
||
if (amount === null || amount === undefined || isNaN(amount)) return '0.00';
|
||
return Number(amount).toLocaleString('zh-CN', {
|
||
minimumFractionDigits: decimals,
|
||
maximumFractionDigits: decimals
|
||
});
|
||
},
|
||
|
||
// 格式化市值为亿元
|
||
formatMarketCap(value) {
|
||
if (!value || value === '-') return '-';
|
||
const num = parseFloat(value);
|
||
if (isNaN(num)) return value;
|
||
// 转换为亿元
|
||
const yi = num / 100000000;
|
||
return yi.toLocaleString('zh-CN', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
}) + '亿';
|
||
},
|
||
|
||
// 基本面弹窗中的AI分析(流式输出)
|
||
async aiAnalyze(code, name) {
|
||
if (!code) return;
|
||
|
||
this.aiAnalyzing = true;
|
||
this.aiAnalysisResult = '';
|
||
this.aiReasoningResult = '';
|
||
|
||
// 使用EventSource接收流式数据
|
||
const eventSource = new EventSource(`/api/ai_analyze_stream/${code}`);
|
||
|
||
eventSource.onmessage = (event) => {
|
||
if (event.data === '[DONE]') {
|
||
eventSource.close();
|
||
this.aiAnalyzing = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.type === 'reasoning') {
|
||
this.aiReasoningResult += data.content;
|
||
} else if (data.type === 'content') {
|
||
this.aiAnalysisResult += data.content;
|
||
} else if (data.type === 'error') {
|
||
this.showToast(data.content, 'error');
|
||
eventSource.close();
|
||
this.aiAnalyzing = false;
|
||
}
|
||
} catch (e) {
|
||
console.error('解析SSE数据失败', e);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = (e) => {
|
||
console.error('SSE连接错误', e);
|
||
eventSource.close();
|
||
this.aiAnalyzing = false;
|
||
if (!this.aiAnalysisResult) {
|
||
this.showToast('AI分析连接失败', 'error');
|
||
}
|
||
};
|
||
},
|
||
|
||
// 格式化AI分析结果(支持Markdown格式)
|
||
formatAiAnalysis(text) {
|
||
if (!text) return '';
|
||
return text
|
||
// 处理二级标题
|
||
.replace(/^## (.+)$/gm, '<h4 class="ai-section-title">$1</h4>')
|
||
// 处理三级标题
|
||
.replace(/^### (.+)$/gm, '<h5 class="ai-subsection-title">$1</h5>')
|
||
// 处理加粗
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
// 处理列表项
|
||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||
// 处理换行
|
||
.replace(/\n/g, '<br>')
|
||
// 清理多余的br
|
||
.replace(/<br><h/g, '<h')
|
||
.replace(/<\/h4><br>/g, '</h4>')
|
||
.replace(/<\/h5><br>/g, '</h5>');
|
||
},
|
||
|
||
// 根据股票代码获取名称
|
||
async getStockNameByCode(code) {
|
||
try {
|
||
const response = await axios.get(`/api/fundamental/${code}`);
|
||
if (response.data.success && response.data.data) {
|
||
return response.data.data.stock_name;
|
||
}
|
||
} catch (e) {
|
||
console.debug('获取股票名称失败');
|
||
}
|
||
return null;
|
||
},
|
||
|
||
async analyze() {
|
||
if (!this.stockCode) {
|
||
this.error = '请输入股票代码';
|
||
return;
|
||
}
|
||
|
||
this.loading = true;
|
||
this.error = null;
|
||
this.result = null;
|
||
|
||
try {
|
||
const response = await axios.post('/api/analyze', {
|
||
stock_code: this.stockCode,
|
||
start_date: this.startDate,
|
||
end_date: this.endDate
|
||
});
|
||
|
||
if (response.data.success) {
|
||
this.result = response.data;
|
||
|
||
// 缓存分析结果(用于交易记录页面)
|
||
this.analysisCache[this.stockCode] = response.data;
|
||
|
||
// 保存到历史记录
|
||
this.saveHistory(response.data.stock_code, response.data.stock_name);
|
||
|
||
// 获取最新数据
|
||
this.calculateLatestData();
|
||
|
||
// 如果图表区域已展开,渲染图表
|
||
if (this.expandedSections.charts) {
|
||
this.$nextTick(() => {
|
||
this.renderCharts();
|
||
});
|
||
}
|
||
|
||
// 跳转到提醒页面并刷新
|
||
this.activeTab = 'alerts';
|
||
this.$nextTick(() => {
|
||
this.refreshAlerts();
|
||
});
|
||
} else {
|
||
this.error = response.data.error || '分析失败';
|
||
}
|
||
} catch (err) {
|
||
console.error('请求错误:', err);
|
||
this.error = err.response?.data?.error || err.message || '请求失败';
|
||
this.showToast(this.error, 'error');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
// 从基本面弹窗添加到关注
|
||
async addToWatchFromFundamental() {
|
||
if (!this.fundamentalData) return;
|
||
|
||
const code = this.fundamentalData.stock_code;
|
||
const name = this.fundamentalData.stock_name;
|
||
|
||
if (this.searchHistory.some(h => h.code === code)) {
|
||
this.showToast('该股票已在关注列表中', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 添加到关注列表(通过API)
|
||
try {
|
||
const response = await axios.post('/api/watchlist', { code, name });
|
||
if (response.data.success) {
|
||
this.searchHistory = [...response.data.watchlist];
|
||
|
||
// 强制更新stockAlerts触发关注列表刷新
|
||
this.stockAlerts = [...this.stockAlerts];
|
||
|
||
// 强制更新fundamentalData触发按钮状态刷新
|
||
this.fundamentalData = { ...this.fundamentalData };
|
||
|
||
this.showToast('已添加到关注列表', 'success');
|
||
}
|
||
} catch (e) {
|
||
console.error('添加关注失败', e);
|
||
this.showToast('添加关注失败', 'error');
|
||
}
|
||
},
|
||
|
||
// 从基本面弹窗取消关注
|
||
async removeFromWatchFromFundamental() {
|
||
if (!this.fundamentalData) return;
|
||
|
||
const code = this.fundamentalData.stock_code;
|
||
|
||
try {
|
||
const response = await axios.delete(`/api/watchlist/${code}`);
|
||
if (response.data.success) {
|
||
this.searchHistory = [...response.data.watchlist];
|
||
// 同时从stockAlerts中移除
|
||
this.stockAlerts = this.stockAlerts.filter(a => a.code !== code);
|
||
|
||
// 强制更新fundamentalData触发按钮状态刷新
|
||
this.fundamentalData = { ...this.fundamentalData };
|
||
|
||
this.showToast('已取消关注', 'success');
|
||
}
|
||
} catch (e) {
|
||
console.error('取消关注失败', e);
|
||
this.showToast('取消关注失败', 'error');
|
||
}
|
||
},
|
||
|
||
async addToWatchFromSignal() {
|
||
if (!this.techSignalResult) return;
|
||
const code = this.techSignalResult.stock_code;
|
||
const name = this.techSignalResult.stock_name;
|
||
if (this.searchHistory.some(h => h.code === code)) {
|
||
this.showToast('该股票已在关注列表中', 'warning');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await axios.post('/api/watchlist', { code, name });
|
||
if (response.data.success) {
|
||
this.searchHistory = [...response.data.watchlist];
|
||
if (!this.stockAlerts.some(a => a.code === code)) {
|
||
try {
|
||
const alertResp = await axios.post('/api/signal_alerts', {
|
||
stocks: [{ code, name }],
|
||
holding_codes: this.holdingStocks
|
||
});
|
||
if (alertResp.data.success && alertResp.data.results?.length) {
|
||
this.stockAlerts = [...this.stockAlerts, ...alertResp.data.results];
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
}
|
||
} catch (e) { /* fallback: alerts will refresh on tab switch */ }
|
||
}
|
||
this.showToast('已添加到关注列表', 'success');
|
||
}
|
||
} catch (e) {
|
||
console.error('添加关注失败', e);
|
||
this.showToast('添加关注失败', 'error');
|
||
}
|
||
},
|
||
|
||
async removeFromWatchFromSignal() {
|
||
if (!this.techSignalResult) return;
|
||
const code = this.techSignalResult.stock_code;
|
||
try {
|
||
const response = await axios.delete(`/api/watchlist/${code}`);
|
||
if (response.data.success) {
|
||
this.searchHistory = [...response.data.watchlist];
|
||
this.stockAlerts = this.stockAlerts.filter(a => a.code !== code);
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
this.showToast('已取消关注', 'success');
|
||
}
|
||
} catch (e) {
|
||
console.error('取消关注失败', e);
|
||
this.showToast('取消关注失败', 'error');
|
||
}
|
||
},
|
||
|
||
async toggleWatchFromScan(code, name) {
|
||
const isWatched = this.searchHistory.some(h => h.code === code);
|
||
try {
|
||
if (isWatched) {
|
||
const response = await axios.delete(`/api/watchlist/${code}`);
|
||
if (response.data.success) {
|
||
this.searchHistory = response.data.watchlist || [];
|
||
this.stockAlerts = this.stockAlerts.filter(a => a.code !== code);
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
this.refreshAlerts(true);
|
||
this.showToast('已取消关注', 'success');
|
||
}
|
||
} else {
|
||
const response = await axios.post('/api/watchlist', { code, name: name || code });
|
||
if (response.data.success) {
|
||
this.searchHistory = response.data.watchlist || [];
|
||
this.refreshAlerts(true);
|
||
this.showToast('已添加关注', 'success');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(isWatched ? '取消关注失败' : '添加关注失败', e);
|
||
this.showToast(isWatched ? '取消关注失败' : '添加关注失败', 'error');
|
||
}
|
||
},
|
||
|
||
calculateLatestData() {
|
||
// 从API返回的最新数据中获取
|
||
const latest = this.result.data['最新数据'];
|
||
|
||
if (latest) {
|
||
this.latestData = {
|
||
date: latest['日期'],
|
||
price: latest['收盘价'],
|
||
change: latest['涨跌幅'],
|
||
pricePosition: latest['价格位置'],
|
||
superRatio: latest['超大单净流入占比'],
|
||
mainRatio: latest['主力净流入占比'],
|
||
superDirection: latest['超大单流向'],
|
||
mainDirection: latest['主力流向']
|
||
};
|
||
} else {
|
||
this.latestData = {
|
||
price: 0,
|
||
pricePosition: 50,
|
||
superRatio: 0,
|
||
mainRatio: 0
|
||
};
|
||
}
|
||
},
|
||
|
||
renderCharts() {
|
||
// 准备数据
|
||
const labels = this.periods;
|
||
|
||
// 超大单数据
|
||
const superInflowData = labels.map(period => {
|
||
const val = this.getComparisonValue('超大单', period, '大额流入表现');
|
||
return val !== null ? val : 0;
|
||
});
|
||
const superOutflowData = labels.map(period => {
|
||
const val = this.getComparisonValue('超大单', period, '大额流出表现');
|
||
return val !== null ? val : 0;
|
||
});
|
||
|
||
// 主力数据
|
||
const mainInflowData = labels.map(period => {
|
||
const val = this.getComparisonValue('主力', period, '大额流入表现');
|
||
return val !== null ? val : 0;
|
||
});
|
||
const mainOutflowData = labels.map(period => {
|
||
const val = this.getComparisonValue('主力', period, '大额流出表现');
|
||
return val !== null ? val : 0;
|
||
});
|
||
|
||
// 销毁旧图表
|
||
if (this.superChart) this.superChart.destroy();
|
||
if (this.mainChart) this.mainChart.destroy();
|
||
|
||
// 超大单图表
|
||
const superCtx = this.$refs.superChart.getContext('2d');
|
||
this.superChart = new Chart(superCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: '大额流入日表现',
|
||
data: superInflowData,
|
||
backgroundColor: 'rgba(231, 76, 60, 0.7)',
|
||
borderColor: 'rgba(231, 76, 60, 1)',
|
||
borderWidth: 1
|
||
},
|
||
{
|
||
label: '大额流出日表现',
|
||
data: superOutflowData,
|
||
backgroundColor: 'rgba(39, 174, 96, 0.7)',
|
||
borderColor: 'rgba(39, 174, 96, 1)',
|
||
borderWidth: 1
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
callback: function(value) {
|
||
return value + '%';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
position: 'top'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 主力图表
|
||
const mainCtx = this.$refs.mainChart.getContext('2d');
|
||
this.mainChart = new Chart(mainCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: '大额流入日表现',
|
||
data: mainInflowData,
|
||
backgroundColor: 'rgba(231, 76, 60, 0.7)',
|
||
borderColor: 'rgba(231, 76, 60, 1)',
|
||
borderWidth: 1
|
||
},
|
||
{
|
||
label: '大额流出日表现',
|
||
data: mainOutflowData,
|
||
backgroundColor: 'rgba(39, 174, 96, 0.7)',
|
||
borderColor: 'rgba(39, 174, 96, 1)',
|
||
borderWidth: 1
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
callback: function(value) {
|
||
return value + '%';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
position: 'top'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
getComparisonValue(flowType, period, key) {
|
||
try {
|
||
const comparison = this.result.data['对比分析'][flowType];
|
||
if (comparison && comparison[period]) {
|
||
return comparison[period][key];
|
||
}
|
||
} catch (e) {}
|
||
return null;
|
||
},
|
||
|
||
formatPercent(value) {
|
||
if (value === null || value === undefined) return '-';
|
||
return value.toFixed(2) + '%';
|
||
},
|
||
|
||
getClass(value) {
|
||
if (value === null || value === undefined) return '';
|
||
return value >= 0 ? 'positive' : 'negative';
|
||
},
|
||
|
||
toggleSection(section) {
|
||
this.expandedSections[section] = !this.expandedSections[section];
|
||
// 如果展开图表区域,需要重新渲染图表
|
||
if (section === 'charts' && this.expandedSections.charts && this.result) {
|
||
this.$nextTick(() => {
|
||
this.renderCharts();
|
||
});
|
||
}
|
||
},
|
||
|
||
// 关注列表相关方法(使用服务器存储)
|
||
async loadHistory() {
|
||
try {
|
||
const response = await axios.get('/api/watchlist');
|
||
if (response.data.success) {
|
||
this.searchHistory = response.data.watchlist;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载关注列表失败', e);
|
||
this.showToast('加载关注列表失败', 'error');
|
||
}
|
||
},
|
||
|
||
async saveHistory(code, name) {
|
||
try {
|
||
const response = await axios.post('/api/watchlist', { code, name });
|
||
if (response.data.success) {
|
||
this.searchHistory = response.data.watchlist;
|
||
}
|
||
} catch (e) {
|
||
console.error('保存关注列表失败', e);
|
||
}
|
||
},
|
||
|
||
selectHistory(item) {
|
||
this.stockCode = item.code;
|
||
this.showHistory = false;
|
||
// 自动开始分析
|
||
this.analyze();
|
||
},
|
||
|
||
async removeHistory(code) {
|
||
try {
|
||
const response = await axios.delete(`/api/watchlist/${code}`);
|
||
if (response.data.success) {
|
||
this.searchHistory = response.data.watchlist;
|
||
}
|
||
} catch (e) {
|
||
console.error('移除关注失败', e);
|
||
this.showToast('移除关注失败', 'error');
|
||
}
|
||
},
|
||
|
||
hideHistory() {
|
||
// 延迟隐藏,让点击事件能够触发
|
||
setTimeout(() => {
|
||
this.showHistory = false;
|
||
}, 200);
|
||
},
|
||
|
||
// ========== 提醒页面相关方法 ==========
|
||
async refreshAlerts(forceRefresh = false) {
|
||
// 合并关注列表和持有股票(去重)
|
||
const allStocks = [...this.searchHistory];
|
||
|
||
// 添加持有的股票(如果不在关注列表中)
|
||
this.holdingStocks.forEach(code => {
|
||
if (!allStocks.some(item => item.code === code)) {
|
||
const trade = this.trades.find(t => t.stock_code === code);
|
||
allStocks.push({
|
||
code: code,
|
||
name: trade?.stock_name || `股票${code}`
|
||
});
|
||
}
|
||
});
|
||
|
||
if (allStocks.length === 0) {
|
||
this.stockAlerts = [];
|
||
this.alertsLoading = false;
|
||
return;
|
||
}
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// 1. 先尝试加载缓存
|
||
if (!forceRefresh) {
|
||
try {
|
||
const cacheResponse = await axios.get('/api/alerts_cache');
|
||
if (cacheResponse.data.success && cacheResponse.data.alerts?.length > 0) {
|
||
const cachedAlerts = cacheResponse.data.alerts;
|
||
const lastUpdate = cacheResponse.data.lastUpdate;
|
||
const cacheDate = lastUpdate ? lastUpdate.split(' ')[0] : null;
|
||
const cacheVer = cacheResponse.data.version || 0;
|
||
|
||
// 版本不匹配则强制刷新
|
||
if (cacheVer < ALERT_CACHE_VERSION) {
|
||
console.debug(`缓存版本过旧(v${cacheVer} < v${ALERT_CACHE_VERSION}),强制刷新`);
|
||
} else {
|
||
this.stockAlerts = cachedAlerts;
|
||
console.debug(`加载缓存数据: ${cachedAlerts.length}条, 更新时间: ${lastUpdate}, v${cacheVer}`);
|
||
|
||
if (cacheDate === today) {
|
||
console.debug('缓存是今天的,无需更新');
|
||
this.alertsLoading = false;
|
||
this.updateHoldingPricesFromAlerts();
|
||
const cachedCodes = new Set(cachedAlerts.map(a => a.code));
|
||
const newStocks = allStocks.filter(s => !cachedCodes.has(s.code));
|
||
if (newStocks.length > 0) {
|
||
console.debug(`发现${newStocks.length}只新股票,增量更新`);
|
||
await this.analyzeNewStocks(newStocks, today);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.debug('加载缓存失败,将重新分析:', err);
|
||
}
|
||
}
|
||
|
||
// 2. 使用信号扫描接口
|
||
this.alertsLoading = true;
|
||
this.alertsProgress = { current: 0, total: allStocks.length };
|
||
|
||
try {
|
||
console.debug(`开始信号分析 ${allStocks.length} 只股票...`);
|
||
const startTime = Date.now();
|
||
|
||
const response = await axios.post('/api/signal_alerts', {
|
||
stocks: allStocks,
|
||
holding_codes: this.holdingStocks
|
||
});
|
||
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.debug(`信号分析完成,耗时 ${elapsed}s`);
|
||
|
||
if (response.data.success) {
|
||
const results = response.data.results || [];
|
||
this.alertsProgress.current = allStocks.length;
|
||
this.stockAlerts = [...results];
|
||
|
||
console.debug(`成功: ${response.data.success_count}`);
|
||
|
||
// 3. 保存到缓存
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
|
||
// 4. 更新持仓现价
|
||
this.updateHoldingPricesFromAlerts();
|
||
|
||
// 5. 后台用实时接口更新提醒中的现价(避免显示表里的旧价)
|
||
this.refreshAlertPricesFromRealtime();
|
||
} else {
|
||
console.error('信号分析失败:', response.data.error);
|
||
}
|
||
} catch (err) {
|
||
console.error('批量分析请求失败:', err);
|
||
// 回退到逐个分析
|
||
console.debug('回退到逐个分析模式...');
|
||
await this.analyzeStocksOneByOne(allStocks, today, forceRefresh);
|
||
}
|
||
|
||
this.alertsLoading = false;
|
||
},
|
||
|
||
// 逐个分析(批量失败时的回退方案)
|
||
async analyzeStocksOneByOne(allStocks, today, forceRefresh) {
|
||
const results = [];
|
||
const BATCH_SIZE = 5;
|
||
|
||
for (let i = 0; i < allStocks.length; i += BATCH_SIZE) {
|
||
const batch = allStocks.slice(i, i + BATCH_SIZE);
|
||
const batchPromises = batch.map(item =>
|
||
this.analyzeStock(item, today).catch(err => {
|
||
console.error(`分析${item.code}失败:`, err);
|
||
return null;
|
||
})
|
||
);
|
||
|
||
const batchResults = await Promise.all(batchPromises);
|
||
batchResults.forEach(alertData => {
|
||
if (alertData) results.push(alertData);
|
||
});
|
||
|
||
this.alertsProgress.current = Math.min(i + BATCH_SIZE, allStocks.length);
|
||
this.stockAlerts = [...results];
|
||
}
|
||
|
||
this.stockAlerts = results;
|
||
|
||
if (forceRefresh && results.length > 0) {
|
||
for (const alert of results) {
|
||
try {
|
||
const resp = await axios.get(`/api/realtime_price/${alert.code}`);
|
||
const priceData = resp.data.price || resp.data.data?.price;
|
||
if (resp.data.success && priceData) {
|
||
const newPrice = priceData;
|
||
const oldPrice = alert.price || 0;
|
||
if (oldPrice > 0 && newPrice > oldPrice) {
|
||
alert.priceDirection = 'up';
|
||
} else if (oldPrice > 0 && newPrice < oldPrice) {
|
||
alert.priceDirection = 'down';
|
||
}
|
||
alert.price = newPrice;
|
||
}
|
||
} catch (err) {}
|
||
}
|
||
this.stockAlerts = [...results];
|
||
}
|
||
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
this.updateHoldingPricesFromAlerts();
|
||
},
|
||
|
||
// 分析单只股票
|
||
async analyzeStock(item, today) {
|
||
const response = await axios.post('/api/analyze', {
|
||
stock_code: item.code,
|
||
start_date: this.startDate,
|
||
end_date: today
|
||
});
|
||
|
||
if (response.data.success) {
|
||
const latest = response.data.data['最新数据'];
|
||
if (latest) {
|
||
const pricePos = latest['价格位置'] || 50;
|
||
const superRatio = latest['超大单净流入占比'] || 0;
|
||
const mainRatio = latest['主力净流入占比'] || 0;
|
||
|
||
const isLow = pricePos <= 50;
|
||
const isHigh = pricePos > 50;
|
||
const isSuperInflow = superRatio >= 2;
|
||
const isSuperOutflow = superRatio <= -2;
|
||
const isMainInflow = mainRatio >= 2;
|
||
const isMainOutflow = mainRatio <= -2;
|
||
|
||
let signalType = 'watch';
|
||
let reason = '无明确信号';
|
||
let recommendRate = 0;
|
||
|
||
if (isLow && (isSuperInflow || isMainInflow)) {
|
||
signalType = 'buy';
|
||
reason = `低位${pricePos.toFixed(0)}% + ${isSuperInflow ? '超大单' : '主力'}流入`;
|
||
const posScore = Math.max(0, (50 - pricePos) / 50) * 50;
|
||
const flowScore = Math.min(Math.max(superRatio, mainRatio), 10) * 5;
|
||
recommendRate = Math.round(posScore + flowScore);
|
||
} else if (isHigh && (isSuperOutflow || isMainOutflow)) {
|
||
signalType = 'sell';
|
||
reason = `高位${pricePos.toFixed(0)}% + ${isSuperOutflow ? '超大单' : '主力'}流出`;
|
||
const posScore = Math.max(0, (pricePos - 50) / 50) * 50;
|
||
const flowScore = Math.min(Math.abs(Math.min(superRatio, mainRatio)), 10) * 5;
|
||
recommendRate = Math.round(posScore + flowScore);
|
||
}
|
||
|
||
const closePrice = latest['收盘价'];
|
||
return {
|
||
code: item.code,
|
||
name: response.data.stock_name || item.name,
|
||
price: closePrice,
|
||
changePct: 0,
|
||
scanPrice: closePrice,
|
||
scanChangePct: 0,
|
||
pricePosition: pricePos,
|
||
superRatio: superRatio,
|
||
mainRatio: mainRatio,
|
||
signalType: signalType,
|
||
reason: reason,
|
||
recommendRate: recommendRate,
|
||
updateDate: today
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// 分析新增股票(增量更新,并行)
|
||
async analyzeNewStocks(newStocks, today) {
|
||
this.alertsLoading = true;
|
||
try {
|
||
const response = await axios.post('/api/signal_alerts', {
|
||
stocks: newStocks,
|
||
holding_codes: this.holdingStocks
|
||
});
|
||
if (response.data.success) {
|
||
const newResults = response.data.results || [];
|
||
newResults.forEach(r => {
|
||
if (!this.stockAlerts.some(a => a.code === r.code)) {
|
||
this.stockAlerts.push(r);
|
||
}
|
||
});
|
||
this.stockAlerts = [...this.stockAlerts];
|
||
}
|
||
} catch (err) {
|
||
console.error('增量信号分析失败:', err);
|
||
this.showToast('信号分析失败,请稍后重试', 'error');
|
||
}
|
||
this.alertsLoading = false;
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
},
|
||
|
||
// 保存分析结果到缓存
|
||
async saveAlertsCache(alerts) {
|
||
try {
|
||
await axios.post('/api/alerts_cache', {
|
||
alerts: alerts,
|
||
lastUpdate: new Date().toISOString().replace('T', ' ').split('.')[0],
|
||
version: ALERT_CACHE_VERSION
|
||
});
|
||
console.debug('分析结果已保存到缓存');
|
||
} catch (err) {
|
||
console.error('保存缓存失败:', err);
|
||
}
|
||
},
|
||
|
||
// 添加关注
|
||
async addToWatch() {
|
||
if (!this.newWatchCode || this.newWatchCode.length < 6) return;
|
||
|
||
const code = this.newWatchCode.trim();
|
||
|
||
// 检查是否已存在
|
||
if (this.searchHistory.some(item => item.code === code)) {
|
||
this.showToast('该股票已在关注列表中', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 获取股票信息
|
||
try {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const response = await axios.post('/api/analyze', {
|
||
stock_code: code,
|
||
start_date: this.startDate,
|
||
end_date: today
|
||
});
|
||
|
||
if (response.data.success) {
|
||
const stockName = response.data.stock_name || code;
|
||
|
||
// 添加到关注列表(通过API)
|
||
await axios.post('/api/watchlist', { code: code, name: stockName });
|
||
await this.loadHistory();
|
||
|
||
// 刷新提醒
|
||
await this.refreshAlerts();
|
||
|
||
// 清空输入并隐藏
|
||
this.newWatchCode = '';
|
||
this.showAddWatch = false;
|
||
|
||
// 切换到关注标签
|
||
this.alertSubTab = 'watching';
|
||
} else {
|
||
this.showToast('添加失败:' + (response.data.error || '未知错误'), 'error');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('添加失败:' + (err.response?.data?.error || err.message), 'error');
|
||
}
|
||
},
|
||
|
||
// 移除关注
|
||
removeFromWatch(code) {
|
||
this.showConfirm('确定移除该股票的关注吗?', async () => {
|
||
// 从关注列表中移除(通过API)
|
||
try {
|
||
const response = await axios.delete(`/api/watchlist/${code}`);
|
||
if (response.data.success) {
|
||
this.searchHistory = response.data.watchlist;
|
||
}
|
||
} catch (e) {
|
||
console.error('移除关注失败', e);
|
||
this.showToast('移除关注失败', 'error');
|
||
}
|
||
|
||
// 从提醒列表中移除
|
||
this.stockAlerts = this.stockAlerts.filter(a => a.code !== code);
|
||
|
||
// 清除缓存
|
||
delete this.analysisCache[code];
|
||
});
|
||
},
|
||
|
||
// 从提醒页面快速交易
|
||
quickTradeFromAlert(alert, tradeType) {
|
||
// 如果股票不在关注列表中,临时添加
|
||
if (!this.searchHistory.some(item => item.code === alert.code)) {
|
||
this.searchHistory.push({
|
||
code: alert.code,
|
||
name: alert.name || `股票${alert.code}`
|
||
});
|
||
}
|
||
|
||
// 预填充交易表单
|
||
this.newTrade.stock_code = alert.code;
|
||
this.newTrade.stock_name = alert.name;
|
||
this.newTrade.trade_type = tradeType;
|
||
this.newTrade.price = alert.price;
|
||
this.newTrade.trade_date = new Date().toISOString().split('T')[0];
|
||
this.newTrade.reason = alert.reason;
|
||
this.selectedStockFromHistory = alert.code;
|
||
this.selectedStockSignal = {
|
||
type: tradeType,
|
||
icon: tradeType === 'buy' ? '✓' : '!',
|
||
title: tradeType === 'buy' ? '买入信号' : '卖出信号',
|
||
description: alert.reason
|
||
};
|
||
|
||
// 切换到交易页面并打开表单
|
||
this.activeTab = 'trades';
|
||
this.showTradeForm = true;
|
||
},
|
||
|
||
// ========== 交易记录相关方法 ==========
|
||
async loadTrades() {
|
||
try {
|
||
const response = await axios.get('/api/trades');
|
||
if (response.data.success) {
|
||
this.trades = response.data.trades || [];
|
||
await this.calculateHoldingPositions();
|
||
this.calculateTradeStats();
|
||
// 检查止损线
|
||
await this.checkStopLoss();
|
||
}
|
||
// 加载可用资金
|
||
await this.loadAvailableCash();
|
||
} catch (err) {
|
||
console.error('加载交易记录失败:', err);
|
||
this.showToast('加载交易记录失败', 'error');
|
||
}
|
||
},
|
||
|
||
async loadAvailableCash() {
|
||
try {
|
||
const response = await axios.get('/api/available_cash');
|
||
if (response.data.success) {
|
||
this.availableCash = response.data.available_cash || 0;
|
||
}
|
||
} catch (err) {
|
||
console.error('加载可用资金失败:', err);
|
||
this.showToast('加载可用资金失败', 'error');
|
||
}
|
||
},
|
||
|
||
startEditCash() {
|
||
this.editingCash = true;
|
||
this.cashInputValue = this.availableCash.toString();
|
||
this.$nextTick(() => {
|
||
const input = this.$refs.cashInput;
|
||
if (input) input.focus();
|
||
});
|
||
},
|
||
|
||
async saveCash() {
|
||
try {
|
||
const amount = parseFloat(this.cashInputValue);
|
||
if (isNaN(amount)) {
|
||
this.showToast('请输入有效金额', 'warning');
|
||
return;
|
||
}
|
||
const response = await axios.put('/api/available_cash', { amount });
|
||
if (response.data.success) {
|
||
this.availableCash = response.data.available_cash;
|
||
this.editingCash = false;
|
||
} else {
|
||
this.showToast('保存失败: ' + (response.data.error || '未知错误'), 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('保存可用资金失败:', err);
|
||
this.showToast('保存失败', 'error');
|
||
}
|
||
},
|
||
|
||
cancelEditCash() {
|
||
this.editingCash = false;
|
||
this.cashInputValue = '';
|
||
},
|
||
|
||
// 计算持仓(不获取现价,等refreshAlerts后再更新)
|
||
async calculateHoldingPositions() {
|
||
const positions = {};
|
||
|
||
// 计算每只股票的持仓数量和成本
|
||
this.trades.forEach(trade => {
|
||
const code = trade.stock_code;
|
||
if (!positions[code]) {
|
||
positions[code] = {
|
||
quantity: 0,
|
||
totalCost: 0,
|
||
name: trade.stock_name,
|
||
currentPrice: 0
|
||
};
|
||
}
|
||
|
||
const qty = parseInt(trade.quantity) || 0;
|
||
const price = parseFloat(trade.price) || 0;
|
||
|
||
if (trade.trade_type === 'buy') {
|
||
positions[code].totalCost += qty * price;
|
||
positions[code].quantity += qty;
|
||
} else if (trade.trade_type === 'sell') {
|
||
// 卖出时按比例减少成本
|
||
const avgCost = positions[code].quantity > 0
|
||
? positions[code].totalCost / positions[code].quantity
|
||
: 0;
|
||
positions[code].totalCost -= qty * avgCost;
|
||
positions[code].quantity -= qty;
|
||
}
|
||
});
|
||
|
||
// 先从 stockAlerts 缓存中更新现价(不发请求)
|
||
const holdingCodes = Object.keys(positions).filter(code => positions[code].quantity > 0);
|
||
for (const code of holdingCodes) {
|
||
const alert = this.stockAlerts.find(a => a.code === code);
|
||
if (alert && alert.price) {
|
||
positions[code].currentPrice = alert.price;
|
||
}
|
||
}
|
||
|
||
this.holdingPositions = positions;
|
||
},
|
||
|
||
// 从分析结果更新持仓现价
|
||
updateHoldingPricesFromAlerts() {
|
||
const holdingCodes = Object.keys(this.holdingPositions).filter(
|
||
code => this.holdingPositions[code].quantity > 0
|
||
);
|
||
for (const code of holdingCodes) {
|
||
const alert = this.stockAlerts.find(a => a.code === code);
|
||
if (alert && alert.price) {
|
||
this.holdingPositions[code].currentPrice = alert.price;
|
||
}
|
||
}
|
||
this.calculateTradeStats();
|
||
},
|
||
|
||
/**
|
||
* 用实时接口更新现价(提醒与交易共用)。
|
||
* @param {string[]} [codesOnly] - 若传则只刷新这些 code(交易 tab 传持仓 code);不传则刷新全部 stockAlerts(提醒 tab)
|
||
* @param {number} [timeoutMs] - 单次请求超时,默认 12s
|
||
*/
|
||
async refreshAlertPricesFromRealtime(codesOnly, timeoutMs = 12000) {
|
||
const list = codesOnly && codesOnly.length > 0
|
||
? (this.stockAlerts || []).filter(a => codesOnly.includes(a.code))
|
||
: (this.stockAlerts || []);
|
||
if (list.length === 0) {
|
||
if (codesOnly && codesOnly.length > 0) {
|
||
// 持仓在 stockAlerts 里可能没有,仍要拉价并写回 holdingPositions
|
||
for (const code of codesOnly) {
|
||
try {
|
||
const resp = await axios.get(`/api/realtime_price/${code}`, { timeout: timeoutMs });
|
||
const priceData = resp.data.price ?? resp.data.data?.price;
|
||
const changeData = resp.data.data?.change;
|
||
if (resp.data.success && priceData != null && this.holdingPositions[code]) {
|
||
this.holdingPositions[code].currentPrice = Number(priceData);
|
||
this.holdingPositions = { ...this.holdingPositions };
|
||
this.calculateTradeStats();
|
||
}
|
||
} catch (e) { console.error(`获取${code}实时价格失败:`, e); }
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const BATCH = 5;
|
||
for (let i = 0; i < list.length; i += BATCH) {
|
||
const batch = list.slice(i, i + BATCH);
|
||
await Promise.all(batch.map(async (alert) => {
|
||
try {
|
||
const resp = await axios.get(`/api/realtime_price/${alert.code}`, { timeout: timeoutMs });
|
||
const priceData = resp.data.price ?? resp.data.data?.price;
|
||
const changeData = resp.data.data?.change;
|
||
if (resp.data.success && priceData != null) {
|
||
const newPrice = Number(priceData);
|
||
const oldPrice = alert.price || 0;
|
||
alert.price = newPrice;
|
||
// 同步更新实时涨跌幅
|
||
if (changeData != null) alert.changePct = Number(changeData);
|
||
if (oldPrice > 0 && newPrice > oldPrice) alert.priceDirection = 'up';
|
||
else if (oldPrice > 0 && newPrice < oldPrice) alert.priceDirection = 'down';
|
||
if (alert.latest_data) alert.latest_data['收盘价'] = newPrice;
|
||
}
|
||
} catch (e) { /* 单只失败忽略 */ }
|
||
}));
|
||
this.stockAlerts = [...this.stockAlerts];
|
||
if (codesOnly) {
|
||
this.updateHoldingPricesFromAlerts();
|
||
this.holdingPositions = { ...this.holdingPositions };
|
||
this.calculateTradeStats();
|
||
}
|
||
}
|
||
this.saveAlertsCache(this.stockAlerts);
|
||
this.updateHoldingPricesFromAlerts();
|
||
},
|
||
|
||
/** 交易 tab:刷新持仓现价,与提醒 tab 共用 refreshAlertPricesFromRealtime,仅传持仓 code */
|
||
async refreshHoldingPrices() {
|
||
const holdingCodes = Object.keys(this.holdingPositions).filter(
|
||
code => this.holdingPositions[code].quantity > 0
|
||
);
|
||
if (holdingCodes.length === 0) return;
|
||
this.priceRefreshing = true;
|
||
try {
|
||
await this.refreshAlertPricesFromRealtime(holdingCodes, 12000);
|
||
await this.checkStopLoss();
|
||
} catch (e) {
|
||
console.error('刷新持仓价格异常:', e);
|
||
this.showToast('刷新持仓价格失败,请稍后重试', 'error');
|
||
} finally {
|
||
this.priceRefreshing = false;
|
||
}
|
||
},
|
||
|
||
// 检查止损线
|
||
async checkStopLoss() {
|
||
try {
|
||
const response = await axios.get('/api/stoploss_check');
|
||
if (response.data.success) {
|
||
this.stopLossAlerts = response.data.alerts || [];
|
||
}
|
||
} catch (error) {
|
||
console.error('检查止损失败:', error);
|
||
this.showToast('检查止损失败,请稍后重试', 'error');
|
||
}
|
||
},
|
||
|
||
// 切换交易组展开/折叠
|
||
toggleTradeGroup(code) {
|
||
if (this.expandedTradeGroups[code]) {
|
||
delete this.expandedTradeGroups[code];
|
||
} else {
|
||
this.expandedTradeGroups[code] = true;
|
||
}
|
||
// 触发响应式更新
|
||
this.expandedTradeGroups = { ...this.expandedTradeGroups };
|
||
},
|
||
|
||
// 加载基本面数据
|
||
async loadFundamental(code, name) {
|
||
this.aiAnalysisResult = '';
|
||
this.aiReasoningResult = '';
|
||
this.aiAnalyzing = false;
|
||
this.showFundamentalModal = true;
|
||
this.fundamentalLoading = true;
|
||
this.fundamentalData = { stock_code: code, stock_name: name, fundFlow: [], klineData: [] };
|
||
|
||
try {
|
||
// 使用 Promise.allSettled 确保单个请求失败不影响其他数据
|
||
const [fundResult, flowResult, klineResult] = await Promise.allSettled([
|
||
axios.get(`/api/fundamental/${code}`),
|
||
axios.get(`/api/fundflow/${code}?days=3`),
|
||
axios.get(`/api/kline/${code}?period=${this.klinePeriod}`)
|
||
]);
|
||
|
||
// 基本面数据
|
||
if (fundResult.status === 'fulfilled' && fundResult.value.data.success) {
|
||
this.fundamentalData = { ...fundResult.value.data.data, fundFlow: [], klineData: [] };
|
||
}
|
||
|
||
// 近3天资金流向
|
||
if (flowResult.status === 'fulfilled' && flowResult.value.data.success) {
|
||
this.fundamentalData.fundFlow = flowResult.value.data.data || [];
|
||
}
|
||
|
||
// K线数据(可能失败,不影响其他数据显示)
|
||
if (klineResult.status === 'fulfilled' && klineResult.value.data.success) {
|
||
this.fundamentalData.klineData = klineResult.value.data.data || [];
|
||
}
|
||
} catch (error) {
|
||
console.error('获取数据失败:', error);
|
||
this.showToast('获取基本面数据失败', 'error');
|
||
} finally {
|
||
this.fundamentalLoading = false;
|
||
// 绘制K线图
|
||
this.$nextTick(() => {
|
||
this.renderKlineChart();
|
||
});
|
||
}
|
||
},
|
||
|
||
// 切换K线周期
|
||
async changeKlinePeriod(period) {
|
||
if (!this.fundamentalData?.stock_code) return;
|
||
this.klinePeriod = period;
|
||
this.klineLoading = true;
|
||
|
||
try {
|
||
const response = await axios.get(`/api/kline/${this.fundamentalData.stock_code}?period=${period}`);
|
||
if (response.data.success) {
|
||
this.fundamentalData.klineData = response.data.data || [];
|
||
this.renderKlineChart();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取K线数据失败:', error);
|
||
this.showToast('获取K线数据失败', 'error');
|
||
} finally {
|
||
this.klineLoading = false;
|
||
}
|
||
},
|
||
|
||
// 渲染K线图(使用收盘价折线图)
|
||
renderKlineChart() {
|
||
if (!this.fundamentalData?.klineData || this.fundamentalData.klineData.length === 0) return;
|
||
|
||
const canvas = this.$refs.klineCanvas;
|
||
if (!canvas) return;
|
||
|
||
// 销毁旧图表
|
||
if (this.klineChart) {
|
||
this.klineChart.destroy();
|
||
}
|
||
|
||
const data = this.fundamentalData.klineData;
|
||
const labels = data.map(d => d.date.slice(5)); // 只显示 MM-DD
|
||
const prices = data.map(d => d.close);
|
||
|
||
// 计算涨跌颜色
|
||
const firstPrice = prices[0];
|
||
const lastPrice = prices[prices.length - 1];
|
||
const isUp = lastPrice >= firstPrice;
|
||
const lineColor = isUp ? '#00ff88' : '#ff4444';
|
||
const bgColor = isUp ? 'rgba(0, 255, 136, 0.1)' : 'rgba(255, 68, 68, 0.1)';
|
||
|
||
this.klineChart = new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
label: '收盘价',
|
||
data: prices,
|
||
borderColor: lineColor,
|
||
backgroundColor: bgColor,
|
||
fill: true,
|
||
tension: 0.3,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 4,
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (ctx) => `¥${ctx.raw.toFixed(2)}`
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
display: true,
|
||
grid: { display: false },
|
||
ticks: {
|
||
color: 'rgba(255,255,255,0.5)',
|
||
font: { size: 10 },
|
||
maxTicksLimit: 6
|
||
}
|
||
},
|
||
y: {
|
||
display: true,
|
||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||
ticks: {
|
||
color: 'rgba(255,255,255,0.5)',
|
||
font: { size: 10 },
|
||
callback: (v) => '¥' + v.toFixed(2)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 从止损预警快速卖出
|
||
quickSellFromStopLoss(alert) {
|
||
// 添加到关注列表(如果不存在)
|
||
if (!this.searchHistory.some(h => h.code === alert.code)) {
|
||
this.searchHistory.push({ code: alert.code, name: alert.name });
|
||
}
|
||
|
||
this.editingTradeId = null;
|
||
this.selectedStockFromHistory = alert.code;
|
||
this.selectedStockSignal = {
|
||
type: 'sell',
|
||
icon: '🔴',
|
||
title: '止损信号',
|
||
description: alert.message
|
||
};
|
||
this.newTrade = {
|
||
stock_code: alert.code,
|
||
stock_name: alert.name,
|
||
trade_type: 'sell',
|
||
price: alert.current_price,
|
||
quantity: alert.quantity,
|
||
trade_date: new Date().toISOString().split('T')[0],
|
||
reason: alert.message,
|
||
result: 'pending',
|
||
profit_amount: '',
|
||
stop_loss_price: '',
|
||
notes: `止损卖出,亏损 ${alert.profit_percent.toFixed(1)}%`
|
||
};
|
||
this.showTradeForm = true;
|
||
},
|
||
|
||
async submitTrade() {
|
||
// 设置默认日期
|
||
if (!this.newTrade.trade_date) {
|
||
this.newTrade.trade_date = new Date().toISOString().split('T')[0];
|
||
}
|
||
|
||
// 生成唯一ID
|
||
const tradeData = {
|
||
...this.newTrade,
|
||
id: this.editingTradeId || Date.now().toString(),
|
||
created_at: new Date().toISOString()
|
||
};
|
||
|
||
try {
|
||
let response;
|
||
if (this.editingTradeId) {
|
||
// 更新已有记录
|
||
response = await axios.put(`/api/trades/${this.editingTradeId}`, tradeData);
|
||
} else {
|
||
// 添加新记录
|
||
response = await axios.post('/api/trades', tradeData);
|
||
}
|
||
if (response.data.success) {
|
||
this.showTradeForm = false;
|
||
this.resetTradeForm();
|
||
if (response.data.available_cash != null) {
|
||
this.availableCash = response.data.available_cash;
|
||
}
|
||
await this.loadTrades();
|
||
}
|
||
} catch (err) {
|
||
console.error('保存交易记录失败:', err);
|
||
this.showToast('保存失败: ' + (err.response?.data?.error || err.message), 'error');
|
||
}
|
||
},
|
||
|
||
editTrade(trade) {
|
||
this.editingTradeId = trade.id;
|
||
this.newTrade = { ...trade };
|
||
|
||
// 如果股票不在关注列表中,临时添加
|
||
if (!this.searchHistory.some(item => item.code === trade.stock_code)) {
|
||
this.searchHistory.push({
|
||
code: trade.stock_code,
|
||
name: trade.stock_name || `股票${trade.stock_code}`
|
||
});
|
||
}
|
||
|
||
// 设置选中的股票,用于显示在下拉框中
|
||
this.selectedStockFromHistory = trade.stock_code;
|
||
// 设置信号显示
|
||
this.selectedStockSignal = {
|
||
type: trade.trade_type,
|
||
icon: trade.trade_type === 'buy' ? '✓' : '!',
|
||
title: trade.trade_type === 'buy' ? '买入' : '卖出',
|
||
description: trade.reason || ''
|
||
};
|
||
this.showTradeForm = true;
|
||
},
|
||
|
||
deleteTrade(tradeId) {
|
||
this.showConfirm('确定删除这条交易记录吗?', async () => {
|
||
try {
|
||
const response = await axios.delete(`/api/trades/${tradeId}`);
|
||
if (response.data.success) {
|
||
if (response.data.available_cash != null) {
|
||
this.availableCash = response.data.available_cash;
|
||
}
|
||
await this.loadTrades();
|
||
}
|
||
} catch (err) {
|
||
console.error('删除交易记录失败:', err);
|
||
this.showToast('删除失败: ' + (err.response?.data?.error || err.message), 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
resetTradeForm() {
|
||
this.editingTradeId = null;
|
||
this.selectedStockFromHistory = '';
|
||
this.selectedStockSignal = { type: '', icon: '', title: '', description: '' };
|
||
this.newTrade = {
|
||
stock_code: '',
|
||
stock_name: '',
|
||
trade_type: 'buy',
|
||
price: '',
|
||
quantity: '',
|
||
trade_date: new Date().toISOString().split('T')[0], // 默认当日
|
||
reason: '',
|
||
result: 'pending',
|
||
profit_amount: '',
|
||
stop_loss_price: '',
|
||
notes: ''
|
||
};
|
||
},
|
||
|
||
openTradeForm() {
|
||
if (this.allStocksForTrade.length === 0) {
|
||
this.showToast('请先在分析页面查询至少一支股票或有持有股票后再记录交易', 'warning');
|
||
return;
|
||
}
|
||
this.resetTradeForm();
|
||
// 设置默认日期为当日
|
||
this.newTrade.trade_date = new Date().toISOString().split('T')[0];
|
||
this.showTradeForm = true;
|
||
},
|
||
|
||
async onStockSelected() {
|
||
if (!this.selectedStockFromHistory) {
|
||
this.selectedStockSignal = { type: '', icon: '', title: '', description: '' };
|
||
return;
|
||
}
|
||
|
||
const stockCode = this.selectedStockFromHistory;
|
||
const stockItem = this.searchHistory.find(item => item.code === stockCode);
|
||
|
||
// 更新交易表单的股票信息
|
||
this.newTrade.stock_code = stockCode;
|
||
this.newTrade.stock_name = stockItem ? stockItem.name : '';
|
||
|
||
// 检查是否有缓存的分析结果
|
||
if (this.analysisCache[stockCode]) {
|
||
this.applyAnalysisToTrade(this.analysisCache[stockCode]);
|
||
return;
|
||
}
|
||
|
||
// 重新获取最新分析
|
||
try {
|
||
const response = await axios.post('/api/analyze', {
|
||
stock_code: stockCode,
|
||
start_date: this.startDate,
|
||
end_date: this.endDate || new Date().toISOString().split('T')[0]
|
||
});
|
||
|
||
if (response.data.success) {
|
||
// 缓存分析结果
|
||
this.analysisCache[stockCode] = response.data;
|
||
this.applyAnalysisToTrade(response.data);
|
||
} else {
|
||
this.selectedStockSignal = {
|
||
type: '',
|
||
icon: '⚠️',
|
||
title: '无法获取分析',
|
||
description: response.data.error || '分析失败'
|
||
};
|
||
}
|
||
} catch (err) {
|
||
this.selectedStockSignal = {
|
||
type: '',
|
||
icon: '⚠️',
|
||
title: '获取分析失败',
|
||
description: err.message
|
||
};
|
||
}
|
||
},
|
||
|
||
async fetchTechSignals(scanItem) {
|
||
if (!this.techSignalCode) return;
|
||
this.techLoading = true;
|
||
const code = scanItem?.code || this.techSignalCode;
|
||
const name = scanItem?.name || '';
|
||
if (scanItem && scanItem.code) {
|
||
// 方案C:先展示扫描结果,后台请求实时检测
|
||
const scanCount = (scanItem.signal_status || []).filter(s => s.triggered).length;
|
||
this.techSignalResult = {
|
||
stock_code: code,
|
||
stock_name: name,
|
||
signals: [],
|
||
latest_signals: scanItem.latest_signals || [],
|
||
signal_summary: {},
|
||
indicators: scanItem.indicators || {},
|
||
signal_status: scanItem.signal_status || [],
|
||
recommend_type: scanItem.recommend_type,
|
||
recommend_text: scanItem.recommend_text,
|
||
recommend_reason: scanItem.recommend_reason || '',
|
||
recommend_rate: scanItem.recommend_rate,
|
||
has_scan_data: true,
|
||
scan_triggered_count: scanCount,
|
||
final_score: scanItem.final_score,
|
||
technical_score: scanItem.technical_score,
|
||
external_score: scanItem.external_score,
|
||
verdict: scanItem.verdict,
|
||
score_factors: null,
|
||
score_summary: '',
|
||
score_detail_loading: true,
|
||
realtime_loading: true,
|
||
realtime_signal_status: null,
|
||
realtime_triggered_count: null,
|
||
realtime_recommend_text: null,
|
||
realtime_recommend_type: null,
|
||
realtime_recommend_reason: null,
|
||
realtime_recommend_rate: null,
|
||
realtime_indicators: null,
|
||
};
|
||
this.techLoading = false;
|
||
// 后台请求实时检测
|
||
try {
|
||
const holding = (this.holdingStocks || []).join(',');
|
||
const url = holding
|
||
? `/api/technical_signals/${code}?lookback=5&days=120&holding_codes=${encodeURIComponent(holding)}`
|
||
: `/api/technical_signals/${code}?lookback=5&days=120`;
|
||
const resp = await axios.get(url);
|
||
if (resp.data.success && this.techSignalResult && this.techSignalResult.stock_code === code) {
|
||
const rt = resp.data;
|
||
const rtCount = (rt.signal_status || []).filter(s => s.triggered).length;
|
||
this.techSignalResult.realtime_loading = false;
|
||
this.techSignalResult.realtime_signal_status = rt.signal_status || [];
|
||
this.techSignalResult.realtime_triggered_count = rtCount;
|
||
this.techSignalResult.realtime_recommend_text = rt.recommend_text;
|
||
this.techSignalResult.realtime_recommend_type = rt.recommend_type;
|
||
this.techSignalResult.realtime_recommend_reason = rt.recommend_reason;
|
||
this.techSignalResult.realtime_recommend_rate = rt.recommend_rate;
|
||
this.techSignalResult.realtime_indicators = rt.indicators;
|
||
this.techSignalResult.realtime_holding_note = rt.holding_note;
|
||
this.techSignalResult.signals = rt.signals || [];
|
||
}
|
||
} catch (err) {
|
||
if (this.techSignalResult && this.techSignalResult.stock_code === code) {
|
||
this.techSignalResult.realtime_loading = false;
|
||
this.techSignalResult.realtime_error = err.message || '获取失败';
|
||
}
|
||
}
|
||
// 异步请求评分详情(外部因素),传入列表技术得分确保一致
|
||
try {
|
||
const techScore = scanItem.final_score ? Math.round(scanItem.final_score - (scanItem.external_score || 0)) : 50;
|
||
const scoreResp = await axios.get(`/api/stock_score_detail/${code}?tech_score=${techScore}`);
|
||
if (scoreResp.data.success && this.techSignalResult && this.techSignalResult.stock_code === code) {
|
||
this.techSignalResult.score_factors = scoreResp.data.factors || null;
|
||
this.techSignalResult.score_summary = scoreResp.data.summary || '';
|
||
this.techSignalResult.final_score = scoreResp.data.final_score;
|
||
this.techSignalResult.technical_score = scoreResp.data.technical_score;
|
||
this.techSignalResult.external_score = scoreResp.data.external_score;
|
||
this.techSignalResult.verdict = scoreResp.data.verdict;
|
||
}
|
||
} catch (e) {
|
||
console.debug('评分详情获取失败:', e);
|
||
}
|
||
if (this.techSignalResult && this.techSignalResult.stock_code === code) {
|
||
this.techSignalResult.score_detail_loading = false;
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
const holding = (this.holdingStocks || []).join(',');
|
||
const url = holding
|
||
? `/api/technical_signals/${this.techSignalCode}?lookback=5&days=120&holding_codes=${encodeURIComponent(holding)}`
|
||
: `/api/technical_signals/${this.techSignalCode}?lookback=5&days=120`;
|
||
const resp = await axios.get(url);
|
||
if (resp.data.success) {
|
||
this.techSignalResult = resp.data;
|
||
this.techSignalResult.has_scan_data = false;
|
||
}
|
||
} catch (err) {
|
||
console.error('技术信号检测失败:', err);
|
||
this.showToast('技术信号检测失败,请稍后重试', 'error');
|
||
} finally {
|
||
this.techLoading = false;
|
||
}
|
||
},
|
||
|
||
async batchTechSignals() {
|
||
if (!this.searchHistory || this.searchHistory.length === 0) {
|
||
this.showToast('请先添加关注股票', 'warning');
|
||
return;
|
||
}
|
||
this.techLoading = true;
|
||
this.techBatchResults = [];
|
||
try {
|
||
const codes = this.searchHistory.map(h => h.code);
|
||
const resp = await axios.post('/api/batch_technical_signals', {
|
||
codes: codes,
|
||
lookback: 5,
|
||
days: 120,
|
||
holding_codes: this.holdingStocks
|
||
});
|
||
if (resp.data.success) {
|
||
this.techBatchResults = (resp.data.results || [])
|
||
.sort((a, b) => (b.triggered_count || 0) - (a.triggered_count || 0));
|
||
}
|
||
} catch (err) {
|
||
console.error('批量技术信号检测失败:', err);
|
||
this.showToast('批量检测失败,请稍后重试', 'error');
|
||
} finally {
|
||
this.techLoading = false;
|
||
}
|
||
},
|
||
|
||
async fetchFullScanStatus() {
|
||
try {
|
||
const resp = await axios.get('/api/scan_status');
|
||
if (resp.data.success) {
|
||
this.fullScanStatus = resp.data;
|
||
}
|
||
} catch (err) {
|
||
console.error('获取扫描状态失败:', err);
|
||
}
|
||
},
|
||
|
||
async fetchFullScanResults(page) {
|
||
this.fullScanLoading = true;
|
||
if (page) this.fullScanPage = page;
|
||
try {
|
||
const minTriggered = (this.fullScanFilter === 'triggered' || this.fullScanFilter === 'multi') ? 1 : 0;
|
||
let signalTypes = '';
|
||
if (this.fullScanFilterTypes.length > 0) {
|
||
signalTypes = this.fullScanFilterTypes.join(',');
|
||
}
|
||
const resp = await axios.get('/api/scan_results', {
|
||
params: {
|
||
page: this.fullScanPage,
|
||
per_page: this.fullScanPerPage,
|
||
min_triggered: minTriggered,
|
||
signal_type: signalTypes,
|
||
holding_codes: (this.holdingStocks || []).join(','),
|
||
recommend_text: this.fullScanFilterRecommend === 'all' ? '' : this.fullScanFilterRecommend,
|
||
with_scores: true,
|
||
}
|
||
});
|
||
if (resp.data.success) {
|
||
this.fullScanResults = resp.data.results || [];
|
||
this.fullScanTotalPages = resp.data.total_pages || 1;
|
||
this.fullScanSignalDist = resp.data.signal_distribution || [];
|
||
this.fullScanStatus = {
|
||
total: resp.data.total_stocks || resp.data.total_scanned,
|
||
scanned: resp.data.total_scanned,
|
||
triggered: resp.data.triggered_stocks,
|
||
progress: 100,
|
||
is_complete: true,
|
||
scan_date: resp.data.scan_date,
|
||
scan_start: resp.data.scan_start,
|
||
scan_end: resp.data.scan_end,
|
||
recommend_counts: resp.data.recommend_counts || {},
|
||
};
|
||
}
|
||
} catch (err) {
|
||
console.error('获取扫描结果失败:', err);
|
||
this.showToast('获取扫描结果失败,请稍后重试', 'error');
|
||
} finally {
|
||
this.fullScanLoading = false;
|
||
}
|
||
},
|
||
|
||
async fetchBullStocks() {
|
||
this.bullStocksLoading = true;
|
||
try {
|
||
const holding = (this.holdingStocks || []).join(',');
|
||
const resp = await axios.get('/api/bull_stocks', {
|
||
params: holding ? { holdingStocks: holding } : {}
|
||
});
|
||
if (resp.data.success) {
|
||
this.bullStocksData = resp.data;
|
||
} else {
|
||
this.showToast(resp.data.error || '获取牛股数据失败', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('获取牛股数据失败:', err);
|
||
this.showToast('获取牛股数据失败', 'error');
|
||
} finally {
|
||
this.bullStocksLoading = false;
|
||
}
|
||
},
|
||
|
||
async fetchDeepAnalysis() {
|
||
const code = (this.deepCode || '').trim();
|
||
if (!code || code.length < 6) {
|
||
this.showToast('请输入6位股票代码', 'error');
|
||
return;
|
||
}
|
||
this.deepLoading = true;
|
||
this.deepReport = null;
|
||
try {
|
||
const resp = await axios.post('/api/deep_analyze', { stock_code: code });
|
||
if (resp.data.success) {
|
||
this.deepReport = resp.data.report;
|
||
} else {
|
||
this.showToast(resp.data.error || '分析失败', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('深度分析失败:', err);
|
||
this.showToast('深度分析失败: ' + (err.response?.data?.error || err.message), 'error');
|
||
} finally {
|
||
this.deepLoading = false;
|
||
}
|
||
},
|
||
|
||
async fetchBuyAnalysis() {
|
||
if (this.buyAnalysisLoading) return;
|
||
this.buyAnalysisLoading = true;
|
||
this.buyAnalysisList = [];
|
||
this.buyAnalysisProgress = 0;
|
||
try {
|
||
const scanResp = await axios.get('/api/scan_results', {
|
||
params: { per_page: 200, recommend_text: '买入' }
|
||
});
|
||
if (!scanResp.data.success) {
|
||
this.showToast('获取买入推荐失败', 'error');
|
||
return;
|
||
}
|
||
const buyStocks = scanResp.data.results || [];
|
||
this.buyAnalysisTotal = buyStocks.length;
|
||
if (buyStocks.length === 0) {
|
||
this.showToast('当前无买入推荐股票', 'info');
|
||
return;
|
||
}
|
||
const results = [];
|
||
for (let i = 0; i < buyStocks.length; i++) {
|
||
this.buyAnalysisProgress = i + 1;
|
||
try {
|
||
const resp = await axios.post('/api/deep_analyze', {
|
||
stock_code: buyStocks[i].code,
|
||
skip_llm: true
|
||
});
|
||
if (resp.data.success) {
|
||
const report = resp.data.report;
|
||
report._expanded = false;
|
||
results.push(report);
|
||
}
|
||
} catch (e) {
|
||
console.warn('分析失败:', buyStocks[i].code, e.message);
|
||
}
|
||
}
|
||
results.sort((a, b) => b.deep_score - a.deep_score);
|
||
this.buyAnalysisList = results;
|
||
} catch (err) {
|
||
console.error('买入分析失败:', err);
|
||
this.showToast('买入分析失败: ' + (err.message || '未知错误'), 'error');
|
||
} finally {
|
||
this.buyAnalysisLoading = false;
|
||
}
|
||
},
|
||
|
||
getBullStageStocks(stageNum) {
|
||
if (!this.bullStocksData || !this.bullStocksData.stages) return [];
|
||
return this.bullStocksData.stages[String(stageNum)] || [];
|
||
},
|
||
|
||
getBullStageCount(stageNum) {
|
||
if (!this.bullStocksData || !this.bullStocksData.summary) return 0;
|
||
return this.bullStocksData.summary[String(stageNum)] || this.bullStocksData.summary[stageNum] || 0;
|
||
},
|
||
|
||
getBullStageInfo(stageNum) {
|
||
if (!this.bullStocksData || !this.bullStocksData.stage_info) return null;
|
||
return this.bullStocksData.stage_info.find(s => s.stage === stageNum);
|
||
},
|
||
|
||
getScanRecommend(item) {
|
||
if (item.recommend_type != null && item.recommend_text != null)
|
||
return { text: item.recommend_text, cls: item.recommend_type };
|
||
if (!item.signal_status || item.signal_status.length === 0) return { text: '观望', cls: 'watch' };
|
||
const sigMap = {};
|
||
item.signal_status.forEach(s => { sigMap[s.type] = s.triggered; });
|
||
const hasDivergence = sigMap['daily_bottom_divergence'];
|
||
const hasDragon = sigMap['dragon_head'];
|
||
const hasMainWave = sigMap['main_rising_wave'];
|
||
const hasRealDragon = sigMap['true_dragon'];
|
||
const macd = (item.indicators || {}).macd || {};
|
||
const dif = macd.dif, dea = macd.dea;
|
||
if (hasDivergence && hasDragon) return { text: '买入', cls: 'buy' };
|
||
if (hasMainWave) return { text: '加仓', cls: 'buy' };
|
||
if (hasRealDragon) return { text: '持有', cls: 'hold' };
|
||
if (!hasMainWave && dif != null && dea != null && dif < dea) return { text: '卖出', cls: 'sell' };
|
||
if (hasDivergence) return { text: '关注', cls: 'watch-active' };
|
||
if (hasDragon) return { text: '关注', cls: 'watch-active' };
|
||
if (item.triggered_count > 0) return { text: '观察', cls: 'watch' };
|
||
return { text: '观望', cls: 'watch' };
|
||
},
|
||
|
||
changeFullScanFilter(filter) {
|
||
this.fullScanFilter = filter;
|
||
this.fullScanFilterTypes = [];
|
||
this.fullScanPage = 1;
|
||
this.fetchFullScanResults(1);
|
||
},
|
||
|
||
changeFullScanFilterRecommend(recommend) {
|
||
this.fullScanFilterRecommend = recommend;
|
||
this.fullScanPage = 1;
|
||
this.fetchFullScanResults(1);
|
||
},
|
||
getRecommendFilterClass(text) {
|
||
const map = { '买入': 'rec-buy', '加仓': 'rec-buy', '卖出': 'rec-sell', '持有': 'rec-hold', '关注': 'rec-watch', '观察': 'rec-watch', '观望': 'rec-watch' };
|
||
return map[text] || 'rec-watch';
|
||
},
|
||
|
||
toggleSignalFilter(type) {
|
||
const idx = this.fullScanFilterTypes.indexOf(type);
|
||
if (idx >= 0) {
|
||
this.fullScanFilterTypes.splice(idx, 1);
|
||
} else {
|
||
this.fullScanFilterTypes.push(type);
|
||
}
|
||
if (this.fullScanFilterTypes.length > 0) {
|
||
this.fullScanFilter = 'multi';
|
||
} else {
|
||
this.fullScanFilter = 'triggered';
|
||
}
|
||
this.fullScanPage = 1;
|
||
this.fetchFullScanResults(1);
|
||
},
|
||
|
||
async fetchTodaySignal() {
|
||
if (!this.todaySignalStock) {
|
||
this.todaySignalData = null;
|
||
return;
|
||
}
|
||
|
||
const stockCode = this.todaySignalStock;
|
||
const stockItem = this.searchHistory.find(item => item.code === stockCode);
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// 检查缓存
|
||
if (this.analysisCache[stockCode]) {
|
||
this.processSignalData(stockCode, stockItem?.name, this.analysisCache[stockCode]);
|
||
return;
|
||
}
|
||
|
||
// 获取最新分析
|
||
try {
|
||
const response = await axios.post('/api/analyze', {
|
||
stock_code: stockCode,
|
||
start_date: this.startDate,
|
||
end_date: today
|
||
});
|
||
|
||
if (response.data.success) {
|
||
this.analysisCache[stockCode] = response.data;
|
||
this.processSignalData(stockCode, response.data.stock_name, response.data);
|
||
} else {
|
||
this.showToast('获取信号失败: ' + (response.data.error || '未知错误'), 'error');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('获取信号失败: ' + err.message, 'error');
|
||
}
|
||
},
|
||
|
||
processSignalData(stockCode, stockName, analysisData) {
|
||
const latest = analysisData.data['最新数据'];
|
||
if (!latest) {
|
||
this.todaySignalData = null;
|
||
return;
|
||
}
|
||
|
||
const pricePos = latest['价格位置'] || 50;
|
||
const superRatio = latest['超大单净流入占比'] || 0;
|
||
const mainRatio = latest['主力净流入占比'] || 0;
|
||
|
||
// 生成信号
|
||
const isLow = pricePos <= 50;
|
||
const isHigh = pricePos > 50;
|
||
const isSuperInflow = superRatio >= 2;
|
||
const isSuperOutflow = superRatio <= -2;
|
||
const isMainInflow = mainRatio >= 2;
|
||
const isMainOutflow = mainRatio <= -2;
|
||
|
||
let signal = { type: '', icon: '⏸️', title: '观望信号', description: '当前无明确买卖信号,建议继续观察' };
|
||
let reason = '无明确信号';
|
||
|
||
if (isLow && (isSuperInflow || isMainInflow)) {
|
||
signal = {
|
||
type: 'buy',
|
||
icon: '✅',
|
||
title: '买入信号',
|
||
description: `价格处于${pricePos <= 30 ? '低位' : '中低位'}(${pricePos.toFixed(1)}%),${isSuperInflow ? '超大单' : '主力'}大额流入,建议买入,仓位30-50%,止损-5%`
|
||
};
|
||
reason = isSuperInflow ? '低位+超大单流入' : '低位+主力流入';
|
||
} else if (isHigh && (isSuperOutflow || isMainOutflow)) {
|
||
signal = {
|
||
type: 'sell',
|
||
icon: '🔴',
|
||
title: '卖出信号',
|
||
description: `价格处于${pricePos >= 70 ? '高位' : '中高位'}(${pricePos.toFixed(1)}%),${isSuperOutflow ? '超大单' : '主力'}大额流出,建议卖出或减仓`
|
||
};
|
||
reason = isSuperOutflow ? '高位+超大单流出' : '高位+主力流出';
|
||
}
|
||
|
||
this.todaySignalData = {
|
||
stock_code: stockCode,
|
||
stock_name: stockName,
|
||
date: latest['日期'],
|
||
price: latest['收盘价'],
|
||
pricePosition: pricePos,
|
||
superRatio: superRatio,
|
||
mainRatio: mainRatio,
|
||
signal: signal,
|
||
reason: reason
|
||
};
|
||
},
|
||
|
||
quickTrade(tradeType) {
|
||
if (!this.todaySignalData) return;
|
||
|
||
// 预填充交易表单
|
||
this.newTrade.stock_code = this.todaySignalData.stock_code;
|
||
this.newTrade.stock_name = this.todaySignalData.stock_name;
|
||
this.newTrade.trade_type = tradeType;
|
||
this.newTrade.price = this.todaySignalData.price;
|
||
this.newTrade.trade_date = new Date().toISOString().split('T')[0];
|
||
this.newTrade.reason = this.todaySignalData.reason;
|
||
this.selectedStockFromHistory = this.todaySignalData.stock_code;
|
||
this.selectedStockSignal = this.todaySignalData.signal;
|
||
|
||
this.showTradeForm = true;
|
||
},
|
||
|
||
openTradeFormWithStock() {
|
||
if (!this.todaySignalData) return;
|
||
|
||
this.newTrade.stock_code = this.todaySignalData.stock_code;
|
||
this.newTrade.stock_name = this.todaySignalData.stock_name;
|
||
this.newTrade.trade_date = new Date().toISOString().split('T')[0];
|
||
this.newTrade.reason = this.todaySignalData.reason;
|
||
this.selectedStockFromHistory = this.todaySignalData.stock_code;
|
||
this.selectedStockSignal = this.todaySignalData.signal;
|
||
|
||
this.showTradeForm = true;
|
||
},
|
||
|
||
applyAnalysisToTrade(analysisData) {
|
||
const latest = analysisData.data['最新数据'];
|
||
|
||
if (!latest) {
|
||
this.selectedStockSignal = {
|
||
type: '',
|
||
icon: '⚠️',
|
||
title: '无最新数据',
|
||
description: '无法获取最新分析数据'
|
||
};
|
||
return;
|
||
}
|
||
|
||
const pricePos = latest['价格位置'] || 50;
|
||
const superRatio = latest['超大单净流入占比'] || 0;
|
||
const mainRatio = latest['主力净流入占比'] || 0;
|
||
|
||
// 判断信号
|
||
const isLow = pricePos <= 50;
|
||
const isHigh = pricePos > 50;
|
||
const isSuperInflow = superRatio >= 2;
|
||
const isSuperOutflow = superRatio <= -2;
|
||
const isMainInflow = mainRatio >= 2;
|
||
const isMainOutflow = mainRatio <= -2;
|
||
|
||
// 生成信号描述
|
||
let signal = { type: '', icon: '⏸️', title: '观望信号', description: '当前无明确买卖信号' };
|
||
|
||
if (isLow && (isSuperInflow || isMainInflow)) {
|
||
signal = {
|
||
type: 'buy',
|
||
icon: '✅',
|
||
title: '买入信号',
|
||
description: `价格处于${pricePos <= 30 ? '低位' : '中低位'}(${pricePos.toFixed(1)}%),${isSuperInflow ? '超大单' : '主力'}大额流入`
|
||
};
|
||
this.newTrade.reason = isLow && isSuperInflow ? '低位+超大单流入' : '低位+主力流入';
|
||
} else if (isHigh && (isSuperOutflow || isMainOutflow)) {
|
||
signal = {
|
||
type: 'sell',
|
||
icon: '🔴',
|
||
title: '卖出信号',
|
||
description: `价格处于${pricePos >= 70 ? '高位' : '中高位'}(${pricePos.toFixed(1)}%),${isSuperOutflow ? '超大单' : '主力'}大额流出`
|
||
};
|
||
this.newTrade.reason = isHigh && isSuperOutflow ? '高位+超大单流出' : '高位+主力流出';
|
||
} else {
|
||
this.newTrade.reason = '无明确信号-谨慎操作';
|
||
}
|
||
|
||
this.selectedStockSignal = signal;
|
||
},
|
||
|
||
calculateTradeStats() {
|
||
const completedTrades = this.trades.filter(t => t.result !== 'pending');
|
||
const profitTrades = completedTrades.filter(t => t.result === 'profit').length;
|
||
const lossTrades = completedTrades.filter(t => t.result === 'loss').length;
|
||
|
||
// 用平均成本法计算所有已实现盈亏(从卖出交易中计算)
|
||
let realizedProfit = 0;
|
||
const stockGroups = {};
|
||
|
||
// 按股票分组并按时间排序
|
||
this.trades.forEach(trade => {
|
||
const code = trade.stock_code;
|
||
if (!stockGroups[code]) {
|
||
stockGroups[code] = [];
|
||
}
|
||
stockGroups[code].push(trade);
|
||
});
|
||
|
||
// 计算每只股票的已实现盈亏
|
||
Object.values(stockGroups).forEach(trades => {
|
||
// 按时间排序(从早到晚)
|
||
trades.sort((a, b) => {
|
||
if (a.trade_date !== b.trade_date) {
|
||
return a.trade_date.localeCompare(b.trade_date);
|
||
}
|
||
return (a.created_at || '').localeCompare(b.created_at || '');
|
||
});
|
||
|
||
let holdingQty = 0;
|
||
let holdingCost = 0;
|
||
|
||
trades.forEach(trade => {
|
||
const qty = parseInt(trade.quantity) || 0;
|
||
const price = parseFloat(trade.price) || 0;
|
||
|
||
if (trade.trade_type === 'buy') {
|
||
holdingCost += qty * price;
|
||
holdingQty += qty;
|
||
} else if (trade.trade_type === 'sell' && holdingQty > 0) {
|
||
// 计算平均成本
|
||
const avgCost = holdingCost / holdingQty;
|
||
// 已实现盈亏 = (卖出价 - 平均成本) × 卖出数量
|
||
realizedProfit += (price - avgCost) * qty;
|
||
// 更新持仓
|
||
holdingCost -= avgCost * qty;
|
||
holdingQty -= qty;
|
||
}
|
||
});
|
||
});
|
||
|
||
// 计算总市值和总成本(基于现价,仅计算持仓股票)
|
||
let totalMarketValue = 0;
|
||
let totalCost = 0;
|
||
Object.keys(this.holdingPositions).forEach(code => {
|
||
const pos = this.holdingPositions[code];
|
||
if (pos.quantity > 0) {
|
||
totalMarketValue += pos.quantity * (pos.currentPrice || 0);
|
||
totalCost += pos.totalCost;
|
||
}
|
||
});
|
||
|
||
// 浮动盈亏 = 总市值 - 总成本
|
||
const unrealizedProfit = totalMarketValue - totalCost;
|
||
// 总盈亏 = 已实现盈亏 + 浮动盈亏
|
||
const totalProfit = realizedProfit + unrealizedProfit;
|
||
|
||
this.tradeStats = {
|
||
total_trades: this.trades.length,
|
||
completed_trades: completedTrades.length,
|
||
profit_trades: profitTrades,
|
||
loss_trades: lossTrades,
|
||
win_rate: completedTrades.length > 0 ? (profitTrades / completedTrades.length * 100) : 0,
|
||
total_profit: totalProfit,
|
||
total_market_value: totalMarketValue,
|
||
total_cost: totalCost,
|
||
unrealized_profit: unrealizedProfit,
|
||
realized_profit: realizedProfit // 新增:已实现盈亏
|
||
};
|
||
},
|
||
|
||
// ========== 模拟交易相关方法 ==========
|
||
|
||
async loadSimData() {
|
||
// 加载模拟交易数据(含智能引擎数据)
|
||
await Promise.all([
|
||
this.loadSimStats(),
|
||
this.loadSimPositions(),
|
||
this.loadSimTrades(),
|
||
this.loadSmartAlgoConfig(),
|
||
this.loadSmartTemplates(),
|
||
this.loadSmartSignals(),
|
||
this.loadSmartPositionMeta(),
|
||
]);
|
||
},
|
||
|
||
async loadSimStats() {
|
||
try {
|
||
const response = await axios.get('/api/sim/stats');
|
||
if (response.data.success) {
|
||
this.simStats = response.data.stats;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载模拟交易统计失败', e);
|
||
this.showToast('加载模拟交易统计失败', 'error');
|
||
}
|
||
},
|
||
|
||
async loadSimPositions() {
|
||
try {
|
||
const response = await axios.get('/api/sim/positions');
|
||
if (response.data.success) {
|
||
this.simPositions = response.data.positions;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载模拟持仓失败', e);
|
||
this.showToast('加载模拟持仓失败', 'error');
|
||
}
|
||
},
|
||
|
||
async loadSimTrades() {
|
||
try {
|
||
const response = await axios.get('/api/sim/trades');
|
||
if (response.data.success) {
|
||
this.simTrades = response.data.trades;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载模拟交易记录失败', e);
|
||
this.showToast('加载交易记录失败', 'error');
|
||
}
|
||
},
|
||
|
||
// ========== 智能交易引擎方法 ==========
|
||
|
||
async loadSmartAlgoConfig() {
|
||
try {
|
||
const resp = await axios.get('/api/smart/config');
|
||
if (resp.data.success) {
|
||
this.smartAlgoConfig = resp.data.config;
|
||
this.smartAlgoIsDefault = resp.data.is_default;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载算法配置失败', e);
|
||
this.showToast('加载算法配置失败', 'error');
|
||
}
|
||
},
|
||
|
||
async loadSmartTemplates() {
|
||
try {
|
||
const resp = await axios.get('/api/smart/templates');
|
||
if (resp.data.success) {
|
||
this.smartTemplates = resp.data.templates;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载算法模板失败', e);
|
||
this.showToast('加载算法模板失败', 'error');
|
||
}
|
||
},
|
||
|
||
async loadSmartSignals() {
|
||
try {
|
||
const resp = await axios.get('/api/smart/signals?limit=30&days=7');
|
||
if (resp.data.success) {
|
||
this.smartSignals = resp.data.signals;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载信号日志失败', e);
|
||
this.showToast('加载信号日志失败', 'error');
|
||
}
|
||
},
|
||
|
||
async loadSmartPositionMeta() {
|
||
try {
|
||
const resp = await axios.get('/api/smart/position_meta');
|
||
if (resp.data.success) {
|
||
this.smartPositionMeta = resp.data.positions;
|
||
}
|
||
} catch (e) {
|
||
console.error('加载持仓元数据失败', e);
|
||
this.showToast('加载持仓元数据失败', 'error');
|
||
}
|
||
},
|
||
|
||
async applyAlgoTemplate(templateName) {
|
||
try {
|
||
const resp = await axios.post('/api/smart/apply_template', { template_name: templateName });
|
||
if (resp.data.success) {
|
||
this.showToast(resp.data.message, 'success');
|
||
await this.loadSmartAlgoConfig();
|
||
this.smartConfigEditing = false;
|
||
} else {
|
||
this.showToast(resp.data.error, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.showToast('应用模板失败: ' + (e.response?.data?.error || e.message), 'error');
|
||
}
|
||
},
|
||
|
||
async saveSmartConfig() {
|
||
if (!this.smartAlgoConfig) return;
|
||
try {
|
||
const resp = await axios.post('/api/smart/config', this.smartAlgoConfig);
|
||
if (resp.data.success) {
|
||
this.showToast('算法配置已保存', 'success');
|
||
this.smartConfigEditing = false;
|
||
this.smartAlgoIsDefault = false;
|
||
} else {
|
||
this.showToast(resp.data.error, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.showToast('保存失败: ' + (e.response?.data?.error || e.message), 'error');
|
||
}
|
||
},
|
||
|
||
getPositionMeta(stockCode) {
|
||
return this.smartPositionMeta.find(m => m.stock_code === stockCode) || null;
|
||
},
|
||
|
||
getActiveRules(stockCode) {
|
||
const meta = this.getPositionMeta(stockCode);
|
||
if (!meta) return [];
|
||
const rules = [];
|
||
if (meta.breakeven_active) rules.push('🛡️保本');
|
||
if (meta.momentum_trailing_active) rules.push('📈跟踪');
|
||
if (meta.partial_exit_done) rules.push('✂️已减仓');
|
||
return rules;
|
||
},
|
||
|
||
async executeAutoTrade() {
|
||
// 使用智能引擎执行交易
|
||
this.simAutoTrading = true;
|
||
|
||
try {
|
||
this.showToast('正在执行智能交易引擎...', 'info');
|
||
|
||
// 优先使用智能引擎(30秒超时)
|
||
const response = await axios.post('/api/smart/trigger', {}, { timeout: 30000 });
|
||
|
||
if (response.data.success) {
|
||
const results = response.data.results || [];
|
||
const buyCount = results.filter(r => r.type === 'buy').length;
|
||
const sellCount = results.filter(r => ['sell', 'partial_sell'].includes(r.type)).length;
|
||
const algo = response.data.algo || '?';
|
||
const fees = response.data.total_fees || 0;
|
||
const detailReasons = response.data.detail_reasons || [];
|
||
const availableCash = response.data.available_cash || 0;
|
||
|
||
if (buyCount === 0 && sellCount === 0) {
|
||
// 无交易 — 用模态框显示详细原因
|
||
this.smartResultData = {
|
||
algo: algo,
|
||
type: 'no_trade',
|
||
title: '智能引擎执行完成',
|
||
reasons: detailReasons.length > 0 ? detailReasons : ['当前没有符合条件的交易信号'],
|
||
results: [],
|
||
fees: 0,
|
||
availableCash: availableCash,
|
||
summary: '无交易操作'
|
||
};
|
||
this.smartResultVisible = true;
|
||
} else {
|
||
// 有交易 — 用模态框显示交易结果 + 原因
|
||
this.smartResultData = {
|
||
algo: algo,
|
||
type: 'traded',
|
||
title: '智能引擎执行完成',
|
||
reasons: detailReasons,
|
||
results: results,
|
||
fees: fees,
|
||
availableCash: availableCash,
|
||
summary: `买入${buyCount}笔, 卖出${sellCount}笔`
|
||
};
|
||
this.smartResultVisible = true;
|
||
}
|
||
|
||
// 刷新数据
|
||
await this.loadSimData();
|
||
} else {
|
||
this.showToast(response.data.error || '智能交易失败', 'error');
|
||
}
|
||
} catch (e) {
|
||
console.error('智能交易失败', e);
|
||
this.showToast('智能交易失败: ' + (e.response?.data?.error || e.message), 'error');
|
||
} finally {
|
||
this.simAutoTrading = false;
|
||
}
|
||
},
|
||
|
||
// 智能执行结果模态框 — 原因分类
|
||
getReasonClass(reason) {
|
||
if (reason.includes('T+1') || reason.includes('限制')) return 'reason-limit';
|
||
if (reason.includes('涨停') || reason.includes('跌停')) return 'reason-limit';
|
||
if (reason.includes('可用现金') || reason.includes('资金')) return 'reason-cash';
|
||
if (reason.includes('卖出') || reason.includes('止盈') || reason.includes('止损')) return 'reason-sell';
|
||
if (reason.includes('买入')) return 'reason-buy';
|
||
return 'reason-info';
|
||
},
|
||
getReasonIcon(reason) {
|
||
if (reason.includes('T+1')) return '🔒';
|
||
if (reason.includes('涨停') || reason.includes('跌停')) return '🚫';
|
||
if (reason.includes('可用现金') || reason.includes('资金')) return '💰';
|
||
if (reason.includes('卖出') || reason.includes('止盈')) return '📤';
|
||
if (reason.includes('止损')) return '🛡️';
|
||
if (reason.includes('买入')) return '📥';
|
||
return 'ℹ️';
|
||
},
|
||
|
||
confirmResetSim() {
|
||
this.showConfirm('确定要重置模拟交易吗?所有交易记录和持仓将被清空。', async () => {
|
||
try {
|
||
const response = await axios.post('/api/sim/reset');
|
||
if (response.data.success) {
|
||
this.showToast('模拟交易已重置', 'success');
|
||
this.simStats = {};
|
||
this.simPositions = [];
|
||
this.simTrades = [];
|
||
} else {
|
||
this.showToast(response.data.error || '重置失败', 'error');
|
||
}
|
||
} catch (e) {
|
||
this.showToast('重置失败', 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
async updateSimPrices() {
|
||
if (this.simPositions.length === 0) return;
|
||
this.simPriceRefreshing = true;
|
||
const prices = {};
|
||
try {
|
||
await Promise.all(this.simPositions.map(async (pos) => {
|
||
try {
|
||
const resp = await axios.get(`/api/realtime_price/${pos.stock_code}`, { timeout: 12000 });
|
||
if (resp.data.success && (resp.data.price ?? resp.data.data?.price) != null) {
|
||
const p = Number(resp.data.price ?? resp.data.data?.price);
|
||
prices[pos.stock_code] = p;
|
||
pos.current_price = p;
|
||
}
|
||
} catch (e) {
|
||
console.error(`获取${pos.stock_code}价格失败`, e);
|
||
}
|
||
}));
|
||
if (Object.keys(prices).length > 0) {
|
||
await axios.post('/api/sim/update_prices', { prices });
|
||
await this.loadSimStats();
|
||
}
|
||
} finally {
|
||
this.simPriceRefreshing = false;
|
||
}
|
||
}
|
||
}
|
||
}).mount('#app');
|