1 Commits
Author SHA1 Message Date
guaiwoluo2020 ef15b92342 feat: 优化市场视图和新闻存储功能
- Market.vue 新增 K 线图表和技术指标显示
- News.vue 优化新闻列表展示
- news_store.py 增强新闻存储和查询功能
- routes_ea.py 修复路由问题
- wangxxGold.mq5 EA 策略优化
2026-03-17 20:54:45 +08:00
5 changed files with 252 additions and 33 deletions
+128 -1
View File
@@ -41,6 +41,55 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- 重要财经事件提醒 -->
<v-row v-if="topCalendarEvent">
<v-col cols="12">
<v-alert
type="error"
dense
class="mb-2"
dismissible
@input="topCalendarEvent = null"
>
<div class="d-flex align-center">
<v-icon small class="mr-2">mdi-calendar-alert</v-icon>
<v-chip color="error" x-small class="mr-2">
重要性 {{ topCalendarEvent.importance }}
</v-chip>
<v-chip :color="getCurrencyColor(topCalendarEvent.currency)" x-small class="mr-2">
{{ topCalendarEvent.currency }}
</v-chip>
<span class="text-body-2 font-weight-medium">{{ topCalendarEvent.name }}</span>
<v-spacer></v-spacer>
<span class="text-caption grey--text ml-2">
{{ formatEventTime(topCalendarEvent.publish_time) }}
</span>
</div>
<!-- 预测值和实际值 -->
<div class="mt-1 d-flex align-center">
<span class="text-caption mr-3" v-if="topCalendarEvent.forecast">
预测: <strong>{{ topCalendarEvent.forecast }}</strong>
</span>
<span class="text-caption mr-3" v-if="topCalendarEvent.previous">
前值: <strong>{{ topCalendarEvent.previous }}</strong>
</span>
<span class="text-caption success--text" v-if="topCalendarEvent.actual">
实际: <strong>{{ topCalendarEvent.actual }}</strong>
</span>
</div>
<!-- 结果标签 -->
<div v-if="topCalendarEvent.result" class="mt-1">
<v-chip
:color="getEventResultColor(topCalendarEvent.result)"
x-small
>
{{ getEventResultLabel(topCalendarEvent.result) }}
</v-chip>
</div>
</v-alert>
</v-col>
</v-row>
<!-- 转折点提醒通知 --> <!-- 转折点提醒通知 -->
<v-row v-if="pivotAlerts.length > 0"> <v-row v-if="pivotAlerts.length > 0">
<v-col cols="12"> <v-col cols="12">
@@ -748,6 +797,9 @@ export default {
const latestFlashNews = ref(null) const latestFlashNews = ref(null)
const newsWs = ref(null) const newsWs = ref(null)
// 最高等级财经事件
const topCalendarEvent = ref(null)
// 计算属性 // 计算属性
const highPivots = computed(() => { const highPivots = computed(() => {
return allPivots.value return allPivots.value
@@ -1079,6 +1131,73 @@ export default {
return 'info' return 'info'
} }
// 获取最高重要性财经事件
const fetchTopCalendarEvent = async () => {
try {
const response = await fetch('/api/news/upcoming?hours=24')
const data = await response.json()
if (data.status === 'ok' && data.data && data.data.length > 0) {
// 按重要性排序,选择最高重要性的事件
const sortedEvents = data.data
.filter(e => e.name && e.name.length > 2) // 过滤无效名称
.sort((a, b) => {
// 先按重要性排序(高到低)
if (b.importance !== a.importance) return b.importance - a.importance
// 同重要性按时间排序(近到远)
return new Date(a.publish_time) - new Date(b.publish_time)
})
if (sortedEvents.length > 0) {
topCalendarEvent.value = sortedEvents[0]
}
}
} catch (err) {
console.error('获取财经事件失败:', err)
}
}
// 格式化事件时间
const formatEventTime = (timeStr) => {
if (!timeStr) return ''
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 获取货币颜色
const getCurrencyColor = (currency) => {
const colors = {
'USD': 'green',
'EUR': 'blue',
'GBP': 'purple',
'JPY': 'red',
'AUD': 'orange',
'CAD': 'teal',
'CHF': 'indigo',
'CNY': 'deep-orange'
}
return colors[currency] || 'grey'
}
// 获取事件结果颜色
const getEventResultColor = (result) => {
if (result === 'better') return 'success'
if (result === 'worse') return 'error'
if (result === 'in_line') return 'info'
return 'grey'
}
// 获取事件结果标签
const getEventResultLabel = (result) => {
if (result === 'better') return '好于预期'
if (result === 'worse') return '差于预期'
if (result === 'in_line') return '符合预期'
return '未知'
}
const getTrendChipColor = (trend) => { const getTrendChipColor = (trend) => {
if (!trend) return 'grey' if (!trend) return 'grey'
const trendLower = trend.toLowerCase() const trendLower = trend.toLowerCase()
@@ -1291,6 +1410,8 @@ export default {
// 快讯相关 // 快讯相关
fetchLatestFlashNews() fetchLatestFlashNews()
connectNewsWebSocket() connectNewsWebSocket()
// 财经事件
fetchTopCalendarEvent()
// 定时刷新 // 定时刷新
const statusInterval = setInterval(() => { const statusInterval = setInterval(() => {
@@ -1366,7 +1487,13 @@ export default {
// 快讯相关 // 快讯相关
latestFlashNews, latestFlashNews,
formatNewsTime, formatNewsTime,
getImpactColor getImpactColor,
// 财经事件相关
topCalendarEvent,
formatEventTime,
getCurrencyColor,
getEventResultColor,
getEventResultLabel
} }
} }
} }
+35 -17
View File
@@ -87,6 +87,8 @@
v-model="selectedImportance" v-model="selectedImportance"
:items="importanceOptions" :items="importanceOptions"
label="重要性筛选" label="重要性筛选"
item-title="text"
item-value="value"
outlined outlined
dense dense
hide-details hide-details
@@ -95,9 +97,11 @@
</v-col> </v-col>
<v-col cols="12" md="4"> <v-col cols="12" md="4">
<v-select <v-select
v-model="selectedCountry" v-model="selectedCurrency"
:items="countryOptions" :items="currencyOptions"
label="国家筛选" label="货币筛选"
item-title="text"
item-value="value"
outlined outlined
dense dense
hide-details hide-details
@@ -281,7 +285,7 @@
</v-chip> </v-chip>
<span class="font-weight-medium">{{ event.name }}</span> <span class="font-weight-medium">{{ event.name }}</span>
</div> </div>
<v-chip small>{{ event.country }}</v-chip> <v-chip small>{{ event.currency }}</v-chip>
</div> </div>
<div class="text-caption text-grey mb-2"> <div class="text-caption text-grey mb-2">
@@ -356,7 +360,7 @@ export default {
const symbolEvents = ref([]) const symbolEvents = ref([])
const selectedImportance = ref(null) const selectedImportance = ref(null)
const selectedCountry = ref(null) const selectedCurrency = ref(null)
const selectedSymbol = ref('GOLD') const selectedSymbol = ref('GOLD')
const ws = ref(null) const ws = ref(null)
@@ -373,13 +377,27 @@ export default {
{ text: '低影响', value: 1 } { text: '低影响', value: 1 }
] ]
const countryOptions = [ // 货币选项(动态生成)
{ text: '美国 (US)', value: 'US' }, const currencyOptions = computed(() => {
{ text: '日本 (JP)', value: 'JP' }, const currencies = new Set()
{ text: '欧洲 (EU)', value: 'EU' }, calendar.value.forEach(e => {
{ text: '英国 (UK)', value: 'UK' }, if (e.currency) currencies.add(e.currency)
{ text: '中国 (CN)', value: 'CN' } })
] const currencyNames = {
'USD': '美元 (USD)',
'EUR': '欧元 (EUR)',
'GBP': '英镑 (GBP)',
'JPY': '日元 (JPY)',
'AUD': '澳元 (AUD)',
'CAD': '加元 (CAD)',
'CHF': '瑞郎 (CHF)',
'NZD': '纽元 (NZD)'
}
return Array.from(currencies).sort().map(c => ({
text: currencyNames[c] || c,
value: c
}))
})
const symbolOptions = [ const symbolOptions = [
{ text: '黄金 (GOLD)', value: 'GOLD' }, { text: '黄金 (GOLD)', value: 'GOLD' },
@@ -392,7 +410,7 @@ export default {
const calendarHeaders = [ const calendarHeaders = [
{ text: '重要性', value: 'importance', width: 100 }, { text: '重要性', value: 'importance', width: 100 },
{ text: '事件', value: 'name', width: 200 }, { text: '事件', value: 'name', width: 200 },
{ text: '国家', value: 'country', width: 80 }, { text: '货币', value: 'currency', width: 80 },
{ text: '发布时间', value: 'publish_time', width: 150 }, { text: '发布时间', value: 'publish_time', width: 150 },
{ text: '数值', value: 'values', width: 120 }, { text: '数值', value: 'values', width: 120 },
{ text: '结果', value: 'result', width: 100 }, { text: '结果', value: 'result', width: 100 },
@@ -406,8 +424,8 @@ export default {
items = items.filter(e => e.importance === selectedImportance.value) items = items.filter(e => e.importance === selectedImportance.value)
} }
if (selectedCountry.value) { if (selectedCurrency.value) {
items = items.filter(e => e.country === selectedCountry.value) items = items.filter(e => e.currency === selectedCurrency.value)
} }
return items return items
@@ -634,10 +652,10 @@ export default {
flashNews, flashNews,
symbolEvents, symbolEvents,
selectedImportance, selectedImportance,
selectedCountry, selectedCurrency,
selectedSymbol, selectedSymbol,
importanceOptions, importanceOptions,
countryOptions, currencyOptions,
symbolOptions, symbolOptions,
calendarHeaders, calendarHeaders,
filteredCalendar, filteredCalendar,
+72 -6
View File
@@ -10,6 +10,66 @@ from datetime import datetime, timedelta
from typing import List, Dict, Optional from typing import List, Dict, Optional
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
import re
# MT5中表示无效值的特殊数值
MT5_INVALID_VALUE = -9223372036854775808.0
def _clean_invalid_value(value: str) -> str:
"""清理MT5返回的无效值"""
if not value:
return ""
try:
# 检查是否是无效值
num = float(value)
if num == MT5_INVALID_VALUE or num < -1e15:
return ""
# 格式化有效数值
if num == int(num):
return str(int(num))
return value
except (ValueError, TypeError):
return value
def _clean_text(text: str) -> str:
"""清理文本中的控制字符和无效Unicode"""
if not text:
return ""
# 移除控制字符 (0x00-0x1F 和 0x7F)
cleaned = re.sub(r'[\x00-\x1f\x7f]', '', text)
# 移除不可打印字符(保留ASCII、中文等常用字符)
# 保留:ASCII (0x20-0x7E), 中文 (0x4E00-0x9FFF), 其他常用Unicode
result = []
for char in cleaned:
code = ord(char)
if (0x20 <= code <= 0x7E or # ASCII可打印字符
0x4E00 <= code <= 0x9FFF or # CJK统一汉字
0x3000 <= code <= 0x303F or # CJK标点
0xFF00 <= code <= 0xFFEF or # 全角字符
code > 0x9FFF): # 其他Unicode字符
result.append(char)
return ''.join(result)
def _is_valid_name(name: str) -> bool:
"""检查名称是否有效(不是乱码)"""
if not name or len(name) < 2:
return False
# 计算可打印字符的比例
printable_count = 0
for char in name:
code = ord(char)
if (0x20 <= code <= 0x7E or # ASCII可打印字符
0x4E00 <= code <= 0x9FFF or # CJK统一汉字
0x3000 <= code <= 0x303F or # CJK标点
0xFF00 <= code <= 0xFFEF): # 全角字符
printable_count += 1
# 如果可打印字符比例低于70%,认为是乱码
ratio = printable_count / len(name) if name else 0
return ratio >= 0.7
@dataclass @dataclass
@@ -167,18 +227,24 @@ class NewsStore:
if publish_time < expiry_threshold: if publish_time < expiry_threshold:
continue continue
# 清理并检查名称有效性
cleaned_name = _clean_text(event_data.get('name', ''))
if not _is_valid_name(cleaned_name):
# 名称无效(乱码),跳过此事件
continue
# 创建事件对象 # 创建事件对象
event = CalendarEvent( event = CalendarEvent(
id=event_id, id=event_id,
name=event_data.get('name', ''), name=cleaned_name,
name_en=event_data.get('name_en', ''), name_en=_clean_text(event_data.get('name_en', '')),
country=event_data.get('country', ''), country=_clean_text(event_data.get('country', '')),
currency=event_data.get('currency', ''), currency=event_data.get('currency', ''),
importance=event_data.get('importance', 0), importance=event_data.get('importance', 0),
publish_time=publish_time, publish_time=publish_time,
forecast=event_data.get('forecast', ''), forecast=_clean_invalid_value(event_data.get('forecast', '')),
previous=event_data.get('previous', ''), previous=_clean_invalid_value(event_data.get('previous', '')),
actual=event_data.get('actual', ''), actual=_clean_invalid_value(event_data.get('actual', '')),
unit=event_data.get('unit', ''), unit=event_data.get('unit', ''),
symbols=event_data.get('symbols', []), symbols=event_data.get('symbols', []),
event_type=event_data.get('event_type', '') event_type=event_data.get('event_type', '')
+5 -5
View File
@@ -274,14 +274,14 @@ def create_ea_routes(server: TradingServer) -> APIRouter:
print(f"[calendar] 收到请求, 数据长度: {len(raw_text)} 字节") print(f"[calendar] 收到请求, 数据长度: {len(raw_text)} 字节")
# 清理所有控制字符 (0x00-0x1F, 除了 \t \n \r) # 清理所有控制字符 (0x00-0x1F 和 0x7F)
# 保留 tab(0x09), LF(0x0A), CR(0x0D) # JSON字符串值中不能包含任何原始控制字符
def clean_control_chars(text): def clean_control_chars(text):
# 使用正则表达式一次性清理所有控制字符 # 使用正则表达式一次性清理所有控制字符
# 除了 tab(0x09), LF(0x0A), CR(0x0D) # 包括 TAB(0x09), LF(0x0A), CR(0x0D), DEL(0x7F) 在内
# 因为 JSON 字符串值中这些字符必须转义,不能直接出现
import re import re
# 匹配所有控制字符 (0x00-0x1F) 除了 \t \n \r pattern = re.compile(r'[\x00-\x1f\x7f]')
pattern = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]')
cleaned = pattern.sub('', text) cleaned = pattern.sub('', text)
removed_count = len(text) - len(cleaned) removed_count = len(text) - len(cleaned)
if removed_count > 0: if removed_count > 0:
+12 -4
View File
@@ -1525,7 +1525,8 @@ void SendCalendarToPython()
//+------------------------------------------------------------------+ //+------------------------------------------------------------------+
//| JSON字符串转义 | //| JSON字符串转义 |
//| 转义所有JSON无效的控制字符(0x00-0x1F) | //| 转义所有JSON无效的控制字符(0x00-0x1F)和非ASCII字符 |
//| 非ASCII字符使用\uXXXX格式转义,确保Unicode正确传递 |
//+------------------------------------------------------------------+ //+------------------------------------------------------------------+
string EscapeJsonString(string str) string EscapeJsonString(string str)
{ {
@@ -1544,15 +1545,22 @@ string EscapeJsonString(string str)
case 0x08: result += "\\b"; break; // backspace case 0x08: result += "\\b"; break; // backspace
case 0x0C: result += "\\f"; break; // form feed case 0x0C: result += "\\f"; break; // form feed
default: default:
// 转义所有其他控制字符 (0x00-0x1F) // 转义所有控制字符 (0x00-0x1F) 和 DEL (0x7F)
if(ch < 32) if(ch < 32 || ch == 0x7F)
{ {
// 使用 \uXXXX 格式转义 // 使用 \uXXXX 格式转义
result += "\\u" + StringFormat("%04X", ch); result += "\\u" + StringFormat("%04X", ch);
} }
else if(ch < 128)
{
// ASCII 字符直接添加
result += CharToString((uchar)ch);
}
else else
{ {
result += CharToString((uchar)ch); // 非ASCII字符(如中文)使用 \uXXXX 格式转义
// 这样可以避免 StringToCharArray 的编码问题
result += "\\u" + StringFormat("%04X", ch);
} }
} }
} }