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