Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef15b92342 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user