Record news signals forward for a no-trade edge test

The trader labels crypto news published after 2026-10-16 15:00 with
the configured model (category and BTC direction) and, four hours
later, backfills BTC/ETH 1m opens at publish, +5m, +1h and +4h from
the klines table. A drawer on the trading page shows the cumulative
returns of trading the labelled direction, after fees. Nothing is
traded; this only collects an out-of-sample record.
This commit is contained in:
eezd committed 2026-10-16 14:47:06 +08:00
1 parent 0397e30870
commit 23a9a5175e
11 files changed
+534 -4

No files matched your search

@@ -0,0 +1,39 @@
"""news signals
Revision ID: c5e8a1b3d207
Revises: a7d3e5f20c18
Create Date: 2026-10-16 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c5e8a1b3d207'
down_revision: Union[str, Sequence[str], None] = 'a7d3e5f20c18'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'news_signals',
sa.Column('news_id', sa.BigInteger(), nullable=False),
sa.Column('category', sa.String(length=16), nullable=False),
sa.Column('direction', sa.String(length=8), nullable=False),
sa.Column('model', sa.String(length=64), nullable=False),
sa.Column('labeled_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('prices', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['news_id'], ['news_items.id']),
sa.PrimaryKeyConstraint('news_id'),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('news_signals')
+60 -1
View File
@@ -17,11 +17,13 @@ from alphapulse.api.market import call_binance, client as market_client
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
from alphapulse.models import AiProvider, NewsItem, NewsSignal, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get
from alphapulse.trading.executor import MANUAL_ACTION_KEY
from alphapulse.trading.news_signals import FORWARD_START, SYMBOLS as SIGNAL_SYMBOLS, news_text
from alphapulse.trading.positions import build_positions
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
from alphapulse.trading.signal_stats import Signal, summarize
from alphapulse.trading.snapshot import read_snapshot
router = APIRouter(prefix="/api/trading", tags=["trading"], dependencies=[Depends(require_admin)])
@@ -179,6 +181,37 @@ class PositionHistoryPage(BaseModel):
items: list[PositionHistoryItem]
class NewsSignalStat(BaseModel):
category: str
symbol: str
horizon: str
n: int
mean_bp: float | None
t: float | None
# 扣除来回手续费后的平均收益
net_bp: float | None
win_rate: float | None
class NewsSignalItem(BaseModel):
news_id: int
published_at: datetime
source_code: str
title: str
category: str
direction: str
# 进场后的原始涨跌(bp,未按方向取正负):{symbol: {"1h": ..., "4h": ...}};未满 4 小时为空
returns: dict[str, dict[str, float]] | None
class NewsSignalsOut(BaseModel):
start: datetime
labeled: int
filled: int
stats: list[NewsSignalStat]
recent: list[NewsSignalItem]
async def get_account(session: AsyncSession) -> TradingAccount:
return await session.get(TradingAccount, 1) or TradingAccount(id=1)
@@ -454,6 +487,32 @@ async def list_position_history_symbols(session: Annotated[AsyncSession, Depends
return list(await session.scalars(select(TradingFill.symbol).distinct().order_by(TradingFill.symbol)))
@router.get("/news-signals")
async def list_news_signals(session: Annotated[AsyncSession, Depends(get_session)]) -> NewsSignalsOut:
"""新闻信号前向记录:按标注方向交易的累计统计和最近 50 条"""
rows = (await session.execute(
select(NewsSignal, NewsItem.published_at, NewsItem.source_code, news_text())
.join(NewsItem, NewsItem.id == NewsSignal.news_id).order_by(NewsItem.published_at)
)).all()
filled = [Signal(published_at, s.category, s.direction, s.prices) for s, published_at, _, _ in rows if s.prices]
horizons = ("1h", "4h")
def returns(prices: dict | None) -> dict[str, dict[str, float]] | None:
if prices is None:
return None
return {sym: {h: (float(p[h]) / float(p["entry"]) - 1) * 1e4 for h in horizons} for sym, p in prices.items()}
return NewsSignalsOut(
start=FORWARD_START, labeled=len(rows), filled=len(filled),
stats=[NewsSignalStat(**r) for r in summarize(filled, SIGNAL_SYMBOLS, horizons)],
recent=[
NewsSignalItem(news_id=s.news_id, published_at=published_at, source_code=source, title=title,
category=s.category, direction=s.direction, returns=returns(s.prices))
for s, published_at, source, title in reversed(rows[-50:])
],
)
@router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""排队一次手动分析:该币停止交易时为试运行,计划只记录、不下单"""
+2 -1
View File
@@ -2,7 +2,7 @@ from alphapulse.core.db import Base
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source
from alphapulse.models.trading import TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
from alphapulse.models.trading import NewsSignal, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
__all__ = [
"AiProvider",
@@ -16,6 +16,7 @@ __all__ = [
"KlineSyncState",
"MarketSymbol",
"NewsItem",
"NewsSignal",
"NewsRevision",
"RawBody",
"RawResponse",
+16
View File
@@ -145,3 +145,19 @@ class TradingFill(Base):
commission: Mapped[Decimal] = mapped_column(Numeric)
commission_asset: Mapped[str] = mapped_column(String(16))
time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class NewsSignal(Base):
"""新闻信号前向记录:加密相关新闻的模型标注,以及发布后 BTC/ETH 的价格,检验按标注方向交易是否有优势。只记录,不下单"""
__tablename__ = "news_signals"
news_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("news_items.id"), primary_key=True)
# price_report(行情播报)/ event(事件)/ irrelevant(与币价无关)
category: Mapped[str] = mapped_column(String(16))
# 对 BTC 短期(几小时)的影响:bullish / bearish / neutral
direction: Mapped[str] = mapped_column(String(8))
model: Mapped[str] = mapped_column(String(64))
labeled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 发布 4 小时后回填的 1m 开盘价:{symbol: {"open": 发布所在分钟, "entry": +5 分钟, "1h": +65 分钟, "4h": +245 分钟}};为空表示尚未回填
prices: Mapped[dict | None] = mapped_column(JSONB)
+3 -1
View File
@@ -1,4 +1,4 @@
"""模拟盘交易进程:同步账户快照、执行器对账、调度分析。
"""模拟盘交易进程:同步账户快照、执行器对账、调度分析、记录新闻信号。
用法:python -m alphapulse.trading.engine
"""
@@ -13,6 +13,7 @@ from alphapulse.core.db import SessionLocal
from alphapulse.models import TradingAnalysis, TradingSymbol
from alphapulse.trading.analyst import run_analysis
from alphapulse.trading.executor import executor_loop
from alphapulse.trading.news_signals import news_signal_loop
from alphapulse.trading.schedule import routine_requests
from alphapulse.trading.snapshot import snapshot_loop
@@ -74,6 +75,7 @@ async def main() -> None:
tg.create_task(snapshot_loop())
tg.create_task(executor_loop())
tg.create_task(analysis_loop())
tg.create_task(news_signal_loop())
if __name__ == "__main__":
@@ -0,0 +1,144 @@
"""新闻信号前向记录:给新进来的加密相关新闻打标签(类别、对 BTC 的方向),发布 4 小时后回填 BTC/ETH 的价格。
研究(2026-10-16,见 CONTEXT.md):事件新闻按模型标注方向持有 4 小时,样本内约 +15bp 但不显著,且模型可能记得那段行情。
前向记录只看标注时还没发生的行情,用来确认或否定这条线索。只记录,不下单。
"""
import asyncio
import logging
import re
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert
from alphapulse.ai import llm
from alphapulse.ai.tools import LOCAL_TZ
from alphapulse.core.db import SessionLocal
from alphapulse.models import AiProvider, Kline, NewsItem, NewsSignal, TradingAccount
logger = logging.getLogger("alphapulse.trading.news_signals")
# 研究样本截止到 2026-10-16 14:15,之后发布的新闻才计入,两份样本互不重叠
FORWARD_START = datetime(2026, 10, 16, 15, 0, tzinfo=LOCAL_TZ)
CRYPTO_PATTERN = "比特币|加密货币|以太坊|加密市场|数字货币|稳定币|Coinbase|微策略|币安|BTC"
SYMBOLS = ("BTCUSDT", "ETHUSDT")
# 发布所在分钟之后第几分钟的 1m 开盘价:发布后第 5 分钟进场(抓取加分析约 1–3 分钟),持有 1h、4h
OFFSETS = {"open": 0, "entry": 5, "1h": 65, "4h": 245}
LOOP_SECONDS = 60
BATCH = 40
CATEGORIES = {"price_report", "event", "irrelevant"}
DIRECTIONS = {"bullish", "bearish", "neutral"}
SYSTEM = """你是加密货币新闻分类员。只根据给出的新闻文字判断,不要使用你对之后行情的任何记忆。
对每条新闻给出:
- category:price_report(只是在报道价格已经发生的涨跌,如「比特币跌破 78000 美元」「比特币日内涨超 4%」,包括概念股跟随币价的涨跌);
event(会影响币价的事件或信息,如机构增持/减持、ETF 资金流、监管政策、宏观政策对加密市场的影响、黑客攻击、交易所动态);
irrelevant(与 BTC/ETH 价格基本无关,如个别公司财报细节、分析师调整目标价、技术合作)。
- direction:对 BTC 价格短期(几小时内)的影响,bullish / bearish / neutral。price_report 按它报道的涨跌方向填。
用 label 工具一次提交全部结果,id 与输入一一对应。"""
TOOL = llm.ToolSpec("label", "提交分类结果", {
"type": "object",
"properties": {"items": {"type": "array", "items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"category": {"type": "string", "enum": sorted(CATEGORIES)},
"direction": {"type": "string", "enum": sorted(DIRECTIONS)},
},
"required": ["id", "category", "direction"],
}}},
"required": ["items"],
})
def news_text():
"""快讯常常没有标题,取正文开头"""
return func.coalesce(func.nullif(NewsItem.title, ""), func.left(NewsItem.content_text, 120))
def clean(text: str) -> str:
# 去掉「财联社10月7日电,」之类的日期前缀,减少模型联想到具体日期
return re.sub(r"^(财联社)?\d{1,2}月\d{1,2}日电[,,]?", "", text).strip()[:200]
async def label_new() -> int:
"""给还没打标签的加密新闻打标签,返回本轮标注条数。模型漏掉的条目下一轮重试"""
async with SessionLocal() as session:
rows = (await session.execute(
select(NewsItem.id, news_text()).outerjoin(NewsSignal, NewsSignal.news_id == NewsItem.id)
.where(NewsSignal.news_id.is_(None), NewsItem.published_at >= FORWARD_START, news_text().op("~")(CRYPTO_PATTERN))
.order_by(NewsItem.published_at).limit(BATCH)
)).all()
if not rows:
return 0
account = await session.get(TradingAccount, 1)
provider = await session.get(AiProvider, account.provider_id) if account and account.provider_id else await session.scalar(
select(AiProvider).where(AiProvider.is_default)
)
if provider is None:
raise RuntimeError("没有可用的模型配置,无法给新闻打标签")
session.expunge(provider)
completion = None
text = "\n".join(f"{news_id}\t{clean(t)}" for news_id, t in rows)
async for item in llm.stream(provider, SYSTEM, [{"role": "user", "text": text}], [TOOL], "low"):
if isinstance(item, llm.Completion):
completion = item
call = next((c for c in completion.tool_calls if c["name"] == "label"), None)
if call is None:
raise RuntimeError("模型没有调用 label 工具")
ids = {news_id for news_id, _ in rows}
values = [
{"news_id": x["id"], "category": x["category"], "direction": x["direction"], "model": provider.model}
for x in call["arguments"].get("items", [])
if x.get("id") in ids and x.get("category") in CATEGORIES and x.get("direction") in DIRECTIONS
]
if len(values) < len(rows):
logger.warning("新闻标签:%d 条中模型只返回了 %d 条有效结果,其余下一轮重试", len(rows), len(values))
if values:
async with SessionLocal() as session:
await session.execute(insert(NewsSignal).values(values).on_conflict_do_nothing())
await session.commit()
return len(values)
async def fill_prices() -> int:
"""发布满 4 小时的信号回填价格,返回回填条数。1m K 线有缺口时留到下一轮"""
cutoff = datetime.now(UTC) - timedelta(minutes=OFFSETS["4h"] + 2)
filled = 0
async with SessionLocal() as session:
pending = (await session.execute(
select(NewsSignal, NewsItem.published_at).join(NewsItem, NewsItem.id == NewsSignal.news_id)
.where(NewsSignal.prices.is_(None), NewsItem.published_at <= cutoff)
)).all()
for signal, published_at in pending:
minute = int(published_at.timestamp()) // 60 * 60_000
times = {key: minute + offset * 60_000 for key, offset in OFFSETS.items()}
prices = {}
for symbol in SYMBOLS:
opens = dict((await session.execute(
select(Kline.open_time, Kline.open).where(
Kline.market == "um", Kline.symbol == symbol, Kline.interval == "1m", Kline.open_time.in_(times.values()),
)
)).tuples().all())
if len(opens) < len(times):
break
prices[symbol] = {key: str(opens[t]) for key, t in times.items()}
else:
signal.prices = prices
filled += 1
await session.commit()
return filled
async def news_signal_loop() -> None:
while True:
try:
labeled = await label_new()
filled = await fill_prices()
if labeled or filled:
logger.info("新闻信号:标注 %d 条,回填价格 %d 条", labeled, filled)
except Exception:
logger.exception("新闻信号记录出错,稍后重试")
await asyncio.sleep(LOOP_SECONDS)
@@ -0,0 +1,54 @@
"""新闻信号的统计(纯函数,便于测试):按模型标注方向交易的收益,同一件事的多条报道只算一次"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
# 多个来源报道同一件事:同一类别 10 分钟内只算第一条
DEDUPE = timedelta(minutes=10)
# 吃单进出各 0.04%
FEE_BP = 8.0
DIRECTION_SIGN = {"bullish": 1, "bearish": -1}
@dataclass
class Signal:
published_at: datetime
category: str
direction: str
# {symbol: {"entry": ..., "1h": ..., "4h": ...}},价格为十进制字符串
prices: dict
def dedupe(signals: list[Signal]) -> list[Signal]:
"""signals 按发布时间排序"""
last: dict[str, datetime] = {}
kept = []
for s in signals:
if s.category not in last or s.published_at - last[s.category] > DEDUPE:
kept.append(s)
last[s.category] = s.published_at
return kept
def summarize(signals: list[Signal], symbols: tuple[str, ...], horizons: tuple[str, ...]) -> list[dict]:
"""每个类别、币、持有时长:利多做多、利空做空的收益(bp),中性不交易"""
rows = []
kept = dedupe(signals)
for category in sorted({s.category for s in kept}):
for symbol in symbols:
for horizon in horizons:
pnl = [
DIRECTION_SIGN[s.direction] * (float(s.prices[symbol][horizon]) / float(s.prices[symbol]["entry"]) - 1) * 1e4
for s in kept if s.category == category and s.direction in DIRECTION_SIGN
]
n = len(pnl)
mean = sum(pnl) / n if n else None
sd = math.sqrt(sum((x - mean) ** 2 for x in pnl) / (n - 1)) if n > 1 else None
rows.append({
"category": category, "symbol": symbol, "horizon": horizon, "n": n,
"mean_bp": mean, "t": mean / (sd / math.sqrt(n)) if sd else None,
"net_bp": mean - FEE_BP if mean is not None else None,
"win_rate": sum(x > 0 for x in pnl) / n if n else None,
})
return rows
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime, timedelta
import pytest
from alphapulse.trading.signal_stats import Signal, dedupe, summarize
T0 = datetime(2026, 10, 16, 15, 0)
def signal(minutes: float, category: str, direction: str, entry: str = "100", after: str = "101") -> Signal:
return Signal(T0 + timedelta(minutes=minutes), category, direction, {"BTCUSDT": {"entry": entry, "1h": after, "4h": after}})
def test_dedupe_keeps_first_report_of_same_story_per_category():
kept = dedupe([signal(0, "event", "bullish"), signal(5, "event", "bearish"), signal(5, "price_report", "bullish"), signal(11, "event", "bullish")])
assert [(s.published_at - T0, s.category) for s in kept] == [
(timedelta(0), "event"), (timedelta(minutes=5), "price_report"), (timedelta(minutes=11), "event"),
]
def test_summarize_trades_label_direction_and_skips_neutral():
rows = summarize([
signal(0, "event", "bullish", after="101"), # 做多 +100bp
signal(20, "event", "bearish", after="101"), # 做空 −100bp
signal(40, "event", "bearish", after="98"), # 做空 +200bp
signal(60, "event", "neutral", after="150"), # 中性不交易
], ("BTCUSDT",), ("4h",))
assert len(rows) == 1
row = rows[0]
assert (row["n"], row["win_rate"]) == (3, pytest.approx(2 / 3))
assert row["mean_bp"] == pytest.approx(200 / 3)
assert row["net_bp"] == pytest.approx(200 / 3 - 8)
+39
View File
@@ -319,6 +319,45 @@ export function listPositionHistorySymbols(): Promise<string[]> {
return http.get("/trading/positions/history/symbols")
}
export type SignalHorizon = "1h" | "4h"
/** 新闻信号前向记录:按模型标注方向(利多做多、利空做空)交易的统计;收益单位 bp */
export interface NewsSignalStat {
category: "price_report" | "event" | "irrelevant"
symbol: string
horizon: SignalHorizon
n: number
mean_bp: number | null
t: number | null
/** 扣除来回手续费后的平均收益 */
net_bp: number | null
win_rate: number | null
}
export interface NewsSignalItem {
news_id: number
published_at: string
source_code: string
title: string
category: NewsSignalStat["category"]
direction: "bullish" | "bearish" | "neutral"
/** 进场后的原始涨跌(bp,未按方向取正负);发布未满 4 小时为空 */
returns: Record<string, Record<SignalHorizon, number>> | null
}
export interface NewsSignals {
/** 前向记录的起点:之后发布的新闻才计入 */
start: string
labeled: number
filled: number
stats: NewsSignalStat[]
recent: NewsSignalItem[]
}
export function getNewsSignals(): Promise<NewsSignals> {
return http.get("/trading/news-signals")
}
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
return http.get(`/trading/symbols/${symbol}/plan`)
}
@@ -0,0 +1,136 @@
<script lang="ts" setup>
import type { NewsSignalItem, NewsSignals } from "@/apis/trading"
import { getNewsSignals } from "@/apis/trading"
import { useSourceNames } from "@/composables/useSourceNames"
import { formatTime } from "@/utils/text"
const visible = defineModel<boolean>({ required: true })
const CATEGORY_LABELS = { event: "事件", price_report: "行情播报", irrelevant: "无关" } as const
const DIRECTION_LABELS = { bullish: "利多", bearish: "利空", neutral: "中性" } as const
const DIRECTION_TYPES = { bullish: "success", bearish: "danger", neutral: "info" } as const
const sourceNames = useSourceNames()
const data = ref<NewsSignals>()
const loading = ref(false)
watch(visible, async (open) => {
if (!open)
return
loading.value = true
try {
data.value = await getNewsSignals()
} finally {
loading.value = false
}
})
// 无关类不交易,只看事件和行情播报
const stats = computed(() => (data.value?.stats ?? []).filter(s => s.category !== "irrelevant"))
function bp(value: number | null) {
return value === null ? "-" : `${value >= 0 ? "+" : ""}${value.toFixed(1)}`
}
function pnlClass(value: number | null) {
return value === null ? "" : value > 0 ? "text-emerald-600" : value < 0 ? "text-rose-600" : ""
}
/** 按标注方向取正负后的收益:利多看涨、利空看跌 */
function signed(row: NewsSignalItem, symbol: string) {
const ret = row.returns?.[symbol]?.["4h"]
if (ret === undefined || row.direction === "neutral")
return null
return row.direction === "bullish" ? ret : -ret
}
</script>
<template>
<el-drawer v-model="visible" title="新闻信号(前向记录)" size="70%">
<div v-loading="loading" class="flex flex-col gap-3 text-sm">
<el-alert type="info" :closable="false">
<div class="leading-6">
只记录、不下单:{{ data ? formatTime(data.start, "MM-DD HH:mm") : "-" }} 之后发布的加密相关新闻,由模型标注类别和对 BTC 的方向,
按标注方向(利多做多、利空做空)在发布后第 5 分钟进场,看持有 1 小时、4 小时的收益;扣费按来回 8bp。
研究样本里事件新闻 4 小时约 +15bp,但不显著;要确认这个幅度,大约需要 250 条事件信号(约 12 个月)。
</div>
</el-alert>
<div v-if="data" class="text-xs text-slate-500">
已标注 <b class="num text-slate-900">{{ data.labeled }}</b> 条,其中满 4 小时、已回填价格 <b class="num text-slate-900">{{ data.filled }}</b> 条
(同一件事 10 分钟内的多条报道只算一次)
</div>
<el-table :data="stats" cell-class-name="num" empty-text="还没有满 4 小时的信号">
<el-table-column label="类别" width="90">
<template #default="{ row }">
<span class="font-sans">{{ CATEGORY_LABELS[row.category as keyof typeof CATEGORY_LABELS] }}</span>
</template>
</el-table-column>
<el-table-column prop="symbol" label="币种" width="100" />
<el-table-column prop="horizon" label="持有" width="70" />
<el-table-column prop="n" label="笔数" width="70" />
<el-table-column label="平均(bp)" min-width="90">
<template #default="{ row }">
<span :class="pnlClass(row.mean_bp)">{{ bp(row.mean_bp) }}</span>
</template>
</el-table-column>
<el-table-column label="t 值" min-width="70">
<template #default="{ row }">
{{ row.t === null ? "-" : row.t.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="扣费后(bp)" min-width="100">
<template #default="{ row }">
<b :class="pnlClass(row.net_bp)">{{ bp(row.net_bp) }}</b>
</template>
</el-table-column>
<el-table-column label="胜率" min-width="70">
<template #default="{ row }">
{{ row.win_rate === null ? "-" : `${(row.win_rate * 100).toFixed(1)}%` }}
</template>
</el-table-column>
</el-table>
<div class="mt-2 text-slate-900 font-semibold">
最近 50 条
</div>
<el-table :data="data?.recent ?? []" empty-text="还没有新的加密新闻">
<el-table-column label="发布时间" width="120" class-name="num">
<template #default="{ row }">
{{ formatTime(row.published_at, "MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">
<span class="text-xs text-slate-500">{{ sourceNames[row.source_code] ?? row.source_code }}</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
{{ row.title }}
</template>
</el-table-column>
<el-table-column label="标注" width="120">
<template #default="{ row }">
<div class="flex gap-1">
<el-tag size="small" type="info">
{{ CATEGORY_LABELS[row.category as keyof typeof CATEGORY_LABELS] }}
</el-tag>
<el-tag size="small" :type="DIRECTION_TYPES[row.direction as keyof typeof DIRECTION_TYPES]">
{{ DIRECTION_LABELS[row.direction as keyof typeof DIRECTION_LABELS] }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column v-for="symbol in ['BTCUSDT', 'ETHUSDT']" :key="symbol" :label="`${symbol.replace('USDT', '')} 4h(bp)`" width="110" class-name="num">
<template #default="{ row }">
<span v-if="!row.returns" class="font-sans text-xs text-slate-400">未满 4 小时</span>
<span v-else :class="pnlClass(signed(row as NewsSignalItem, symbol))" title="按标注方向取正负:正数表示方向判断对了">
{{ bp(signed(row as NewsSignalItem, symbol)) }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</el-drawer>
</template>
+9 -1
View File
@@ -1,13 +1,14 @@
<script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings"
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import { Clock, Plus } from "@element-plus/icons-vue"
import { Clock, Plus, Reading } from "@element-plus/icons-vue"
import { EFFORTS, listProviders } from "@/apis/settings"
import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, manualAction, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text"
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
import AnalysisDrawer from "./components/AnalysisDrawer.vue"
import NewsSignalDrawer from "./components/NewsSignalDrawer.vue"
import PlanCard from "./components/PlanCard.vue"
import PositionHistoryDrawer from "./components/PositionHistoryDrawer.vue"
@@ -30,6 +31,7 @@ const requesting = ref(false)
const drawerVisible = ref(false)
const drawerAnalysisId = ref<number | null>(null)
const historyVisible = ref(false)
const signalsVisible = ref(false)
const snapshot = computed(() => status.value?.snapshot ?? null)
const account = computed(() => snapshot.value?.account ?? null)
@@ -381,10 +383,15 @@ onBeforeUnmount(() => clearInterval(timer))
<template #header>
<div class="flex items-center justify-between">
持仓({{ positions.length }})
<div>
<el-button link type="primary" :icon="Reading" @click="signalsVisible = true">
新闻信号
</el-button>
<el-button link type="primary" :icon="Clock" @click="historyVisible = true">
历史仓位
</el-button>
</div>
</div>
</template>
<el-table :data="positions" empty-text="没有持仓" cell-class-name="num">
<el-table-column label="币种" min-width="120">
@@ -591,6 +598,7 @@ onBeforeUnmount(() => clearInterval(timer))
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
<PositionHistoryDrawer v-model="historyVisible" @detail="openAnalysis" />
<NewsSignalDrawer v-model="signalsVisible" />
<AnalysisDrawer v-model="drawerVisible" :analysis-id="drawerAnalysisId" />
</div>
</template>