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-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-col cols="12">
@@ -748,6 +797,9 @@ export default {
const latestFlashNews = ref(null)
const newsWs = ref(null)
// 最高等级财经事件
const topCalendarEvent = ref(null)
// 计算属性
const highPivots = computed(() => {
return allPivots.value
@@ -1079,6 +1131,73 @@ export default {
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) => {
if (!trend) return 'grey'
const trendLower = trend.toLowerCase()
@@ -1291,6 +1410,8 @@ export default {
// 快讯相关
fetchLatestFlashNews()
connectNewsWebSocket()
// 财经事件
fetchTopCalendarEvent()
// 定时刷新
const statusInterval = setInterval(() => {
@@ -1366,7 +1487,13 @@ export default {
// 快讯相关
latestFlashNews,
formatNewsTime,
getImpactColor
getImpactColor,
// 财经事件相关
topCalendarEvent,
formatEventTime,
getCurrencyColor,
getEventResultColor,
getEventResultLabel
}
}
}
+35 -17
View File
@@ -87,6 +87,8 @@
v-model="selectedImportance"
:items="importanceOptions"
label="重要性筛选"
item-title="text"
item-value="value"
outlined
dense
hide-details
@@ -95,9 +97,11 @@
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="selectedCountry"
:items="countryOptions"
label="国家筛选"
v-model="selectedCurrency"
:items="currencyOptions"
label="货币筛选"
item-title="text"
item-value="value"
outlined
dense
hide-details
@@ -281,7 +285,7 @@
</v-chip>
<span class="font-weight-medium">{{ event.name }}</span>
</div>
<v-chip small>{{ event.country }}</v-chip>
<v-chip small>{{ event.currency }}</v-chip>
</div>
<div class="text-caption text-grey mb-2">
@@ -356,7 +360,7 @@ export default {
const symbolEvents = ref([])
const selectedImportance = ref(null)
const selectedCountry = ref(null)
const selectedCurrency = ref(null)
const selectedSymbol = ref('GOLD')
const ws = ref(null)
@@ -373,13 +377,27 @@ export default {
{ text: '低影响', value: 1 }
]
const countryOptions = [
{ text: '美国 (US)', value: 'US' },
{ text: '日本 (JP)', value: 'JP' },
{ text: '欧洲 (EU)', value: 'EU' },
{ text: '英国 (UK)', value: 'UK' },
{ text: '中国 (CN)', value: 'CN' }
]
// 货币选项(动态生成)
const currencyOptions = computed(() => {
const currencies = new Set()
calendar.value.forEach(e => {
if (e.currency) currencies.add(e.currency)
})
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 = [
{ text: '黄金 (GOLD)', value: 'GOLD' },
@@ -392,7 +410,7 @@ export default {
const calendarHeaders = [
{ text: '重要性', value: 'importance', width: 100 },
{ text: '事件', value: 'name', width: 200 },
{ text: '国家', value: 'country', width: 80 },
{ text: '货币', value: 'currency', width: 80 },
{ text: '发布时间', value: 'publish_time', width: 150 },
{ text: '数值', value: 'values', width: 120 },
{ text: '结果', value: 'result', width: 100 },
@@ -406,8 +424,8 @@ export default {
items = items.filter(e => e.importance === selectedImportance.value)
}
if (selectedCountry.value) {
items = items.filter(e => e.country === selectedCountry.value)
if (selectedCurrency.value) {
items = items.filter(e => e.currency === selectedCurrency.value)
}
return items
@@ -634,10 +652,10 @@ export default {
flashNews,
symbolEvents,
selectedImportance,
selectedCountry,
selectedCurrency,
selectedSymbol,
importanceOptions,
countryOptions,
currencyOptions,
symbolOptions,
calendarHeaders,
filteredCalendar,
+72 -6
View File
@@ -10,6 +10,66 @@ from datetime import datetime, timedelta
from typing import List, Dict, Optional
import threading
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
@@ -167,18 +227,24 @@ class NewsStore:
if publish_time < expiry_threshold:
continue
# 清理并检查名称有效性
cleaned_name = _clean_text(event_data.get('name', ''))
if not _is_valid_name(cleaned_name):
# 名称无效(乱码),跳过此事件
continue
# 创建事件对象
event = CalendarEvent(
id=event_id,
name=event_data.get('name', ''),
name_en=event_data.get('name_en', ''),
country=event_data.get('country', ''),
name=cleaned_name,
name_en=_clean_text(event_data.get('name_en', '')),
country=_clean_text(event_data.get('country', '')),
currency=event_data.get('currency', ''),
importance=event_data.get('importance', 0),
publish_time=publish_time,
forecast=event_data.get('forecast', ''),
previous=event_data.get('previous', ''),
actual=event_data.get('actual', ''),
forecast=_clean_invalid_value(event_data.get('forecast', '')),
previous=_clean_invalid_value(event_data.get('previous', '')),
actual=_clean_invalid_value(event_data.get('actual', '')),
unit=event_data.get('unit', ''),
symbols=event_data.get('symbols', []),
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)} 字节")
# 清理所有控制字符 (0x00-0x1F, 除了 \t \n \r)
# 保留 tab(0x09), LF(0x0A), CR(0x0D)
# 清理所有控制字符 (0x00-0x1F 和 0x7F)
# JSON字符串值中不能包含任何原始控制字符
def clean_control_chars(text):
# 使用正则表达式一次性清理所有控制字符
# 除了 tab(0x09), LF(0x0A), CR(0x0D)
# 包括 TAB(0x09), LF(0x0A), CR(0x0D), DEL(0x7F) 在内
# 因为 JSON 字符串值中这些字符必须转义,不能直接出现
import re
# 匹配所有控制字符 (0x00-0x1F) 除了 \t \n \r
pattern = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]')
pattern = re.compile(r'[\x00-\x1f\x7f]')
cleaned = pattern.sub('', text)
removed_count = len(text) - len(cleaned)
if removed_count > 0:
+12 -4
View File
@@ -1525,7 +1525,8 @@ void SendCalendarToPython()
//+------------------------------------------------------------------+
//| JSON字符串转义 |
//| 转义所有JSON无效的控制字符(0x00-0x1F) |
//| 转义所有JSON无效的控制字符(0x00-0x1F)和非ASCII字符 |
//| 非ASCII字符使用\uXXXX格式转义,确保Unicode正确传递 |
//+------------------------------------------------------------------+
string EscapeJsonString(string str)
{
@@ -1544,15 +1545,22 @@ string EscapeJsonString(string str)
case 0x08: result += "\\b"; break; // backspace
case 0x0C: result += "\\f"; break; // form feed
default:
// 转义所有其他控制字符 (0x00-0x1F)
if(ch < 32)
// 转义所有控制字符 (0x00-0x1F) 和 DEL (0x7F)
if(ch < 32 || ch == 0x7F)
{
// 使用 \uXXXX 格式转义
result += "\\u" + StringFormat("%04X", ch);
}
else if(ch < 128)
{
// ASCII 字符直接添加
result += CharToString((uchar)ch);
}
else
{
result += CharToString((uchar)ch);
// 非ASCII字符(如中文)使用 \uXXXX 格式转义
// 这样可以避免 StringToCharArray 的编码问题
result += "\\u" + StringFormat("%04X", ch);
}
}
}