Spaces:
Running
Running
Jimin Huang
commited on
Commit
·
5fa7a59
1
Parent(s):
d404e4f
add: Feature
Browse files- Dockerfile +26 -0
- README.md +33 -22
- docker-compose.yml +37 -0
- index.html +9 -8
- package-lock.json +0 -0
- package.json +25 -26
- src/App.vue +11 -77
- src/assets/images/assets_images/AAPL.png +3 -0
- src/assets/images/assets_images/BMRN.png +3 -0
- src/assets/images/assets_images/BTC.png +3 -0
- src/assets/images/assets_images/ETH.png +3 -0
- src/assets/images/assets_images/MRNA.png +3 -0
- src/assets/images/assets_images/MSFT.png +3 -0
- src/assets/images/assets_images/TSLA.png +3 -0
- src/assets/images/companies_images/deepkin_logo.png +3 -0
- src/assets/images/companies_images/logofinai.png +3 -0
- src/assets/images/companies_images/nactemlogo.png +3 -0
- src/assets/images/companies_images/paalai_logo.png +3 -0
- src/components/AgentFilters.vue +322 -0
- src/components/AgentTable.vue +156 -0
- src/components/AssetsFilter.vue +91 -0
- src/components/CompareChart.vue +211 -0
- src/components/ExpansionContent.vue +224 -0
- src/components/Footer.vue +98 -0
- src/components/Header.vue +120 -0
- src/components/PerformanceChart.vue +203 -0
- src/lib/chartColors.js +214 -0
- src/lib/dataCache.js +16 -0
- src/lib/dataService.js +399 -0
- src/lib/idb.js +137 -0
- src/lib/marketCalendar.js +195 -0
- src/lib/perf.js +177 -0
- src/lib/strategies.js +11 -0
- src/lib/supabase.js +32 -0
- src/main.js +89 -0
- src/pages/EquityComparison.vue +350 -0
- src/pages/Main.vue +27 -0
- src/router/index.js +42 -0
- src/views/AddAssetView.vue +11 -0
- src/views/LeadboardView.vue +472 -0
- src/views/LiveView.vue +18 -0
- tpl.env +7 -0
- vite.config.js +13 -0
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS deps
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package.json package-lock.json* ./
|
| 4 |
+
RUN npm ci --no-audit --no-fund || npm i --no-audit --no-fund
|
| 5 |
+
|
| 6 |
+
FROM node:20-alpine AS build
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 9 |
+
COPY . .
|
| 10 |
+
ARG VITE_SUPABASE_URL
|
| 11 |
+
ARG VITE_SUPABASE_ANON_KEY
|
| 12 |
+
ARG VITE_SUPABASE_SERVICE_ROLE_KEY
|
| 13 |
+
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \
|
| 14 |
+
VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY \
|
| 15 |
+
VITE_SUPABASE_SERVICE_ROLE_KEY=$VITE_SUPABASE_SERVICE_ROLE_KEY
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
FROM node:20-alpine AS runner
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
ENV NODE_ENV=production
|
| 21 |
+
COPY --from=build /app/dist ./dist
|
| 22 |
+
RUN npm i -g serve@14.2.1
|
| 23 |
+
EXPOSE 4173
|
| 24 |
+
CMD ["serve", "-s", "dist", "-l", "4173"]
|
| 25 |
+
|
| 26 |
+
|
README.md
CHANGED
|
@@ -1,37 +1,48 @@
|
|
| 1 |
---
|
| 2 |
-
title: Paper Trading
|
| 3 |
emoji: 📈
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
license: apache-2.0
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
- `id` (uuid/text)
|
| 26 |
-
- `agent_name` (text)
|
| 27 |
-
- `asset` (text)
|
| 28 |
-
- `model` (text)
|
| 29 |
-
- `date` (timestamp or text ISO)
|
| 30 |
-
- `price` (numeric)
|
| 31 |
-
- `recommended_action` (text: BUY | SELL | HOLD)
|
| 32 |
-
- `updated_at` (timestamp)
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Paper Trading Viz
|
| 3 |
emoji: 📈
|
| 4 |
colorFrom: indigo
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 4173
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Visualize paper-trading agents from Supabase with a Vue + Vite app.
|
| 10 |
license: apache-2.0
|
| 11 |
---
|
| 12 |
|
| 13 |
+
Paper Trading Viz (Vue + PrimeVue)
|
| 14 |
|
| 15 |
+
环境变量 (.env)
|
| 16 |
|
| 17 |
+
复制 `.env` 并填写 Supabase:
|
| 18 |
|
| 19 |
+
```
|
| 20 |
+
VITE_SUPABASE_URL=https://bipjjabjhvssmwyabaow.supabase.co
|
| 21 |
+
VITE_SUPABASE_ANON_KEY=YOUR_ANON_KEY
|
| 22 |
+
# 推荐仅本地/安全环境使用,前端会暴露
|
| 23 |
+
VITE_SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEY
|
| 24 |
+
```
|
| 25 |
|
| 26 |
+
本地开发
|
| 27 |
|
| 28 |
+
```
|
| 29 |
+
npm i
|
| 30 |
+
npm run dev
|
| 31 |
+
```
|
| 32 |
|
| 33 |
+
Docker 构建与启动
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
```
|
| 36 |
+
docker compose build
|
| 37 |
+
docker compose up -d
|
| 38 |
+
# 访问 http://localhost:4173
|
| 39 |
+
```
|
| 40 |
|
| 41 |
+
页面
|
| 42 |
+
- 首页:筛选 + Agent 卡片
|
| 43 |
+
- 详情:Overview / Performance / Decisions
|
| 44 |
+
- 对比:Equity Comparison 曲线
|
| 45 |
+
|
| 46 |
+
注意:部分绩效/曲线为演示计算,真实回测请在后端提供指标与时间序列。
|
| 47 |
+
|
| 48 |
+
# paper-trading-viz
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
web:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
args:
|
| 6 |
+
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
| 7 |
+
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
|
| 8 |
+
VITE_SUPABASE_SERVICE_ROLE_KEY: ${VITE_SUPABASE_SERVICE_ROLE_KEY}
|
| 9 |
+
VITE_MAX_ROWS: ${VITE_MAX_ROWS:-1000}
|
| 10 |
+
image: paper_trading_viz:web
|
| 11 |
+
container_name: paper_trading_viz_web
|
| 12 |
+
ports:
|
| 13 |
+
- "4173:4173"
|
| 14 |
+
env_file:
|
| 15 |
+
- .env
|
| 16 |
+
environment:
|
| 17 |
+
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
| 18 |
+
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
| 19 |
+
- VITE_SUPABASE_SERVICE_ROLE_KEY=${VITE_SUPABASE_SERVICE_ROLE_KEY}
|
| 20 |
+
- VITE_MAX_ROWS=${VITE_MAX_ROWS:-1000}
|
| 21 |
+
dev:
|
| 22 |
+
image: node:20-alpine
|
| 23 |
+
working_dir: /app
|
| 24 |
+
command: sh -c "npm i && npm run dev -- --host 0.0.0.0"
|
| 25 |
+
ports:
|
| 26 |
+
- "5173:5173"
|
| 27 |
+
env_file:
|
| 28 |
+
- .env
|
| 29 |
+
environment:
|
| 30 |
+
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
| 31 |
+
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
| 32 |
+
- VITE_SUPABASE_SERVICE_ROLE_KEY=${VITE_SUPABASE_SERVICE_ROLE_KEY}
|
| 33 |
+
- VITE_MAX_ROWS=${VITE_MAX_ROWS:-1000}
|
| 34 |
+
volumes:
|
| 35 |
+
- .:/app
|
| 36 |
+
- /app/node_modules
|
| 37 |
+
|
index.html
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html lang="">
|
| 3 |
<head>
|
| 4 |
-
<meta charset="UTF-8"
|
| 5 |
-
<
|
| 6 |
-
<
|
| 7 |
-
<title>Vite App</title>
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="app"></div>
|
| 11 |
-
<script type="module" src="/src/main.
|
| 12 |
</body>
|
| 13 |
-
</html>
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Paper Trading Agents</title>
|
|
|
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="app"></div>
|
| 10 |
+
<script type="module" src="/src/main.js"></script>
|
| 11 |
</body>
|
| 12 |
+
</html>
|
| 13 |
+
|
| 14 |
+
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -1,37 +1,36 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "0.0.0",
|
| 4 |
"private": true,
|
|
|
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
-
"build": "
|
| 9 |
-
"preview": "vite preview"
|
| 10 |
-
"build-only": "vite build",
|
| 11 |
-
"type-check": "vue-tsc --build",
|
| 12 |
-
"lint": "eslint . --fix",
|
| 13 |
-
"format": "prettier --write src/"
|
| 14 |
},
|
| 15 |
"dependencies": {
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
-
"@
|
| 22 |
-
"
|
| 23 |
-
"@vitejs/plugin-vue": "^5.2.3",
|
| 24 |
-
"@vue/eslint-config-prettier": "^10.2.0",
|
| 25 |
-
"@vue/eslint-config-typescript": "^14.5.0",
|
| 26 |
-
"@vue/tsconfig": "^0.7.0",
|
| 27 |
-
"eslint": "^9.22.0",
|
| 28 |
-
"eslint-plugin-vue": "~10.0.0",
|
| 29 |
-
"jiti": "^2.4.2",
|
| 30 |
-
"npm-run-all2": "^7.0.2",
|
| 31 |
-
"prettier": "3.5.3",
|
| 32 |
-
"typescript": "~5.8.0",
|
| 33 |
-
"vite": "^6.2.4",
|
| 34 |
-
"vite-plugin-vue-devtools": "^7.7.2",
|
| 35 |
-
"vue-tsc": "^2.2.8"
|
| 36 |
}
|
| 37 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "paper_trading_viz",
|
|
|
|
| 3 |
"private": true,
|
| 4 |
+
"version": "0.1.0",
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview --host 0.0.0.0 --port 4173"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
+
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
| 13 |
+
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
| 14 |
+
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
| 15 |
+
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
| 16 |
+
"@fortawesome/vue-fontawesome": "^3.1.2",
|
| 17 |
+
"@primevue/themes": "^4.1.0",
|
| 18 |
+
"@supabase/supabase-js": "^2.45.4",
|
| 19 |
+
"chart.js": "^4.5.1",
|
| 20 |
+
"chartjs-plugin-zoom": "^2.2.0",
|
| 21 |
+
"emailjs-com": "^3.2.0",
|
| 22 |
+
"hammerjs": "^2.0.8",
|
| 23 |
+
"install": "^0.13.0",
|
| 24 |
+
"npm": "^11.6.2",
|
| 25 |
+
"nyse-holidays": "^1.2.0",
|
| 26 |
+
"primeflex": "^3.3.1",
|
| 27 |
+
"primeicons": "^7.0.0",
|
| 28 |
+
"primevue": "^4.1.0",
|
| 29 |
+
"vue": "^3.4.38",
|
| 30 |
+
"vue-router": "^4.4.5"
|
| 31 |
},
|
| 32 |
"devDependencies": {
|
| 33 |
+
"@vitejs/plugin-vue": "^5.1.2",
|
| 34 |
+
"vite": "^5.4.2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
}
|
src/App.vue
CHANGED
|
@@ -1,85 +1,19 @@
|
|
| 1 |
-
<script setup lang="ts">
|
| 2 |
-
import { RouterLink, RouterView } from 'vue-router'
|
| 3 |
-
import HelloWorld from './components/HelloWorld.vue'
|
| 4 |
-
</script>
|
| 5 |
-
|
| 6 |
<template>
|
| 7 |
-
<
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
<div class="wrapper">
|
| 11 |
-
<HelloWorld msg="You did it!" />
|
| 12 |
-
|
| 13 |
-
<nav>
|
| 14 |
-
<RouterLink to="/">Home</RouterLink>
|
| 15 |
-
<RouterLink to="/about">About</RouterLink>
|
| 16 |
-
</nav>
|
| 17 |
-
</div>
|
| 18 |
-
</header>
|
| 19 |
-
|
| 20 |
-
<RouterView />
|
| 21 |
</template>
|
| 22 |
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
max-height: 100vh;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
.logo {
|
| 30 |
-
display: block;
|
| 31 |
-
margin: 0 auto 2rem;
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
nav {
|
| 35 |
-
width: 100%;
|
| 36 |
-
font-size: 12px;
|
| 37 |
-
text-align: center;
|
| 38 |
-
margin-top: 2rem;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
nav a.router-link-exact-active {
|
| 42 |
-
color: var(--color-text);
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
nav a.router-link-exact-active:hover {
|
| 46 |
-
background-color: transparent;
|
| 47 |
-
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
border-left: 1px solid var(--color-border);
|
| 53 |
}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
border: 0;
|
| 57 |
}
|
|
|
|
| 58 |
|
| 59 |
-
@media (min-width: 1024px) {
|
| 60 |
-
header {
|
| 61 |
-
display: flex;
|
| 62 |
-
place-items: center;
|
| 63 |
-
padding-right: calc(var(--section-gap) / 2);
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
.logo {
|
| 67 |
-
margin: 0 2rem 0 0;
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
header .wrapper {
|
| 71 |
-
display: flex;
|
| 72 |
-
place-items: flex-start;
|
| 73 |
-
flex-wrap: wrap;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
nav {
|
| 77 |
-
text-align: left;
|
| 78 |
-
margin-left: -1rem;
|
| 79 |
-
font-size: 1rem;
|
| 80 |
|
| 81 |
-
padding: 1rem 0;
|
| 82 |
-
margin-top: 1rem;
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<router-view />
|
| 3 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
</template>
|
| 5 |
|
| 6 |
+
<script>
|
| 7 |
+
export default { name: 'App' }
|
| 8 |
+
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
<style>
|
| 11 |
+
html, body, #app {
|
| 12 |
+
height: 100%;
|
|
|
|
| 13 |
}
|
| 14 |
+
body {
|
| 15 |
+
margin: 0;
|
|
|
|
| 16 |
}
|
| 17 |
+
</style>
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/assets/images/assets_images/AAPL.png
ADDED
|
Git LFS Details
|
src/assets/images/assets_images/BMRN.png
ADDED
|
Git LFS Details
|
src/assets/images/assets_images/BTC.png
ADDED
|
Git LFS Details
|
src/assets/images/assets_images/ETH.png
ADDED
|
Git LFS Details
|
src/assets/images/assets_images/MRNA.png
ADDED
|
Git LFS Details
|
src/assets/images/assets_images/MSFT.png
ADDED
|
Git LFS Details
|
src/assets/images/assets_images/TSLA.png
ADDED
|
Git LFS Details
|
src/assets/images/companies_images/deepkin_logo.png
ADDED
|
Git LFS Details
|
src/assets/images/companies_images/logofinai.png
ADDED
|
Git LFS Details
|
src/assets/images/companies_images/nactemlogo.png
ADDED
|
Git LFS Details
|
src/assets/images/companies_images/paalai_logo.png
ADDED
|
Git LFS Details
|
src/components/AgentFilters.vue
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="filters-compact flex flex-column gap-2 w-full">
|
| 3 |
+
<div class="date-row flex flex-column gap-2">
|
| 4 |
+
<div class="flex align-items-center justify-content-between">
|
| 5 |
+
<span class="text-600">Date Range</span>
|
| 6 |
+
<Button icon="pi pi-undo" size="small" @click="clearDateRange" />
|
| 7 |
+
</div>
|
| 8 |
+
<div v-if="hasDateBounds" class="date-slider-container">
|
| 9 |
+
<Slider v-model="sliderValue" range :min="0" :max="maxSliderValue" class="w-full" @slideend="onSliderEnd" />
|
| 10 |
+
<div class="flex justify-content-between text-xs mt-2" style="color: #64748b;">
|
| 11 |
+
<span>{{ formatDate(sliderStartDate) }}</span>
|
| 12 |
+
<span>{{ formatDate(sliderEndDate) }}</span>
|
| 13 |
+
</div>
|
| 14 |
+
<div v-if="isCalculating" class="text-center mt-2 calculating-text">
|
| 15 |
+
<i class="pi pi-spin pi-spinner"></i> Calculating...
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
<div>
|
| 20 |
+
<div class="mb-1 flex align-items-center justify-content-between text-600 w-full">
|
| 21 |
+
<span>Agents</span>
|
| 22 |
+
<span class="flex gap-1">
|
| 23 |
+
<Button label="All" size="small" severity="primary" rounded @click="selectAll('names')" />
|
| 24 |
+
<Button label="None" size="small" severity="secondary" rounded @click="clearAll('names')" />
|
| 25 |
+
</span>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="flex flex-column gap-1">
|
| 28 |
+
<div v-for="opt in nameOptions" :key="`name_${opt.value}`" class="flex align-items-center gap-1">
|
| 29 |
+
<Checkbox v-model="namesModel" :inputId="`name_${opt.value}`" :value="opt.value" />
|
| 30 |
+
<label :for="`name_${opt.value}`" class="opt-label">{{ opt.label }}</label>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<!-- <Divider />
|
| 36 |
+
|
| 37 |
+
<div>
|
| 38 |
+
<div class="mb-1 flex align-items-center justify-content-between text-600 w-full">
|
| 39 |
+
<span>Assets</span>
|
| 40 |
+
<span class="flex gap-1">
|
| 41 |
+
<Button label="All" size="small" severity="primary" rounded @click="selectAll('assets')" />
|
| 42 |
+
<Button label="None" size="small" severity="secondary" rounded @click="clearAll('assets')" />
|
| 43 |
+
</span>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="flex flex-column gap-1">
|
| 46 |
+
<div v-for="opt in assetOptions" :key="`asset_${opt.value}`" class="flex align-items-center gap-1">
|
| 47 |
+
<Checkbox v-model="assetsModel" :inputId="`asset_${opt.value}`" :value="opt.value" />
|
| 48 |
+
<label :for="`asset_${opt.value}`" class="opt-label">{{ opt.label }}</label>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div> -->
|
| 52 |
+
|
| 53 |
+
<Divider />
|
| 54 |
+
|
| 55 |
+
<div>
|
| 56 |
+
<div class="mb-1 flex align-items-center justify-content-between text-600 w-full">
|
| 57 |
+
<span>Models</span>
|
| 58 |
+
<span class="flex gap-1">
|
| 59 |
+
<Button label="All" size="small" severity="primary" rounded @click="selectAll('models')" />
|
| 60 |
+
<Button label="None" size="small" severity="secondary" rounded @click="clearAll('models')" />
|
| 61 |
+
</span>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="flex flex-column gap-1">
|
| 64 |
+
<div v-for="opt in modelOptions" :key="`model_${opt.value}`" class="flex align-items-center gap-1">
|
| 65 |
+
<Checkbox v-model="modelsModel" :inputId="`model_${opt.value}`" :value="opt.value" />
|
| 66 |
+
<label :for="`model_${opt.value}`" class="opt-label">{{ opt.label }}</label>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<!-- <Divider />
|
| 72 |
+
|
| 73 |
+
<div>
|
| 74 |
+
<div class="mb-1 flex align-items-center justify-content-between text-600 w-full">
|
| 75 |
+
<span>Strategies</span>
|
| 76 |
+
<span class="flex gap-1">
|
| 77 |
+
<Button label="All" size="small" severity="primary" rounded @click="selectAll('strategies')" />
|
| 78 |
+
<Button label="None" size="small" severity="secondary" rounded @click="clearAll('strategies')" />
|
| 79 |
+
</span>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="flex flex-column gap-1">
|
| 82 |
+
<div v-for="opt in strategyOptions" :key="`strategy_${opt.value}`" class="flex align-items-center gap-1">
|
| 83 |
+
<Checkbox v-model="strategiesModel" :inputId="`strategy_${opt.value}`" :value="opt.value" />
|
| 84 |
+
<label :for="`strategy_${opt.value}`" class="opt-label">{{ opt.label }}</label>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div> -->
|
| 88 |
+
</div>
|
| 89 |
+
</template>
|
| 90 |
+
|
| 91 |
+
<script>
|
| 92 |
+
export default {
|
| 93 |
+
name: 'AgentFilters',
|
| 94 |
+
props: {
|
| 95 |
+
modelValue: { type: Object, default: () => ({ names: [], assets: [], models: [], strategies: [] }) },
|
| 96 |
+
nameOptions: { type: Array, default: () => [] },
|
| 97 |
+
assetOptions: { type: Array, default: () => [] },
|
| 98 |
+
modelOptions: { type: Array, default: () => [] },
|
| 99 |
+
strategyOptions: { type: Array, default: () => [] },
|
| 100 |
+
dateBounds: { type: Object, default: () => ({ min: null, max: null }) }
|
| 101 |
+
},
|
| 102 |
+
emits: ['update:modelValue'],
|
| 103 |
+
data() {
|
| 104 |
+
return {
|
| 105 |
+
sliderValue: [0, 100],
|
| 106 |
+
internalUpdate: false,
|
| 107 |
+
isCalculating: false,
|
| 108 |
+
debounceTimer: null
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
computed: {
|
| 112 |
+
hasDateBounds() {
|
| 113 |
+
return this.dateBounds && this.dateBounds.min && this.dateBounds.max
|
| 114 |
+
},
|
| 115 |
+
// Generate all dates between min and max
|
| 116 |
+
allDates() {
|
| 117 |
+
if (!this.hasDateBounds) return []
|
| 118 |
+
const dates = []
|
| 119 |
+
const start = new Date(this.dateBounds.min)
|
| 120 |
+
const end = new Date(this.dateBounds.max)
|
| 121 |
+
const current = new Date(start)
|
| 122 |
+
while (current <= end) {
|
| 123 |
+
dates.push(new Date(current))
|
| 124 |
+
current.setDate(current.getDate() + 1)
|
| 125 |
+
}
|
| 126 |
+
return dates
|
| 127 |
+
},
|
| 128 |
+
maxSliderValue() {
|
| 129 |
+
return Math.max(0, this.allDates.length - 1)
|
| 130 |
+
},
|
| 131 |
+
sliderStartDate() {
|
| 132 |
+
const idx = Array.isArray(this.sliderValue) ? this.sliderValue[0] : 0
|
| 133 |
+
return this.allDates[idx] || this.dateBounds?.min || new Date()
|
| 134 |
+
},
|
| 135 |
+
sliderEndDate() {
|
| 136 |
+
const idx = Array.isArray(this.sliderValue) ? this.sliderValue[1] : this.maxSliderValue
|
| 137 |
+
return this.allDates[idx] || this.dateBounds?.max || new Date()
|
| 138 |
+
},
|
| 139 |
+
datesModel: {
|
| 140 |
+
get(){ return this.modelValue.dates || [] },
|
| 141 |
+
set(v){ this.emitUpdate({ dates: v }) }
|
| 142 |
+
},
|
| 143 |
+
namesModel: {
|
| 144 |
+
get(){ return this.modelValue.names || [] },
|
| 145 |
+
set(v){ this.emitUpdate({ names: v }) }
|
| 146 |
+
},
|
| 147 |
+
assetsModel: {
|
| 148 |
+
get(){ return this.modelValue.assets || [] },
|
| 149 |
+
set(v){ this.emitUpdate({ assets: v }) }
|
| 150 |
+
},
|
| 151 |
+
modelsModel: {
|
| 152 |
+
get(){ return this.modelValue.models || [] },
|
| 153 |
+
set(v){ this.emitUpdate({ models: v }) }
|
| 154 |
+
},
|
| 155 |
+
strategiesModel: {
|
| 156 |
+
get(){ return this.modelValue.strategies || [] },
|
| 157 |
+
set(v){ this.emitUpdate({ strategies: v }) }
|
| 158 |
+
}
|
| 159 |
+
},
|
| 160 |
+
watch: {
|
| 161 |
+
dateBounds: {
|
| 162 |
+
handler() {
|
| 163 |
+
this.initializeSlider()
|
| 164 |
+
},
|
| 165 |
+
immediate: true
|
| 166 |
+
},
|
| 167 |
+
'modelValue.dates': {
|
| 168 |
+
handler(newDates) {
|
| 169 |
+
// Sync slider with external date changes (e.g., from reset)
|
| 170 |
+
if (this.internalUpdate) {
|
| 171 |
+
this.internalUpdate = false
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
if (!newDates || newDates.length === 0) {
|
| 175 |
+
// 重置到默认起始日期 2025-08-01
|
| 176 |
+
this.initializeSlider()
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
methods: {
|
| 182 |
+
initializeSlider() {
|
| 183 |
+
if (this.hasDateBounds) {
|
| 184 |
+
// 查找 2025-08-01 在日期数组中的索引
|
| 185 |
+
const defaultStartDate = new Date('2025-08-01')
|
| 186 |
+
let startIndex = 0
|
| 187 |
+
|
| 188 |
+
// 如果默认起始日期在范围内,找到对应的索引
|
| 189 |
+
if (defaultStartDate >= new Date(this.dateBounds.min) && defaultStartDate <= new Date(this.dateBounds.max)) {
|
| 190 |
+
startIndex = this.allDates.findIndex(d => {
|
| 191 |
+
const dateStr = d.toISOString().split('T')[0]
|
| 192 |
+
return dateStr === '2025-08-01'
|
| 193 |
+
})
|
| 194 |
+
// 如果没找到精确日期,找最接近的日期
|
| 195 |
+
if (startIndex === -1) {
|
| 196 |
+
startIndex = this.allDates.findIndex(d => d >= defaultStartDate)
|
| 197 |
+
if (startIndex === -1) startIndex = 0
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
this.sliderValue = [startIndex, this.maxSliderValue]
|
| 202 |
+
|
| 203 |
+
// 同时设置对应的日期范围,这样表格数据也会被过滤
|
| 204 |
+
const startDate = this.allDates[startIndex]
|
| 205 |
+
const endDate = this.allDates[this.maxSliderValue]
|
| 206 |
+
if (startDate && endDate) {
|
| 207 |
+
this.internalUpdate = true
|
| 208 |
+
this.datesModel = [startDate, endDate]
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
},
|
| 212 |
+
formatDate(date) {
|
| 213 |
+
if (!date) return ''
|
| 214 |
+
try {
|
| 215 |
+
const d = new Date(date)
|
| 216 |
+
return d.toISOString().split('T')[0]
|
| 217 |
+
} catch(_) {
|
| 218 |
+
return ''
|
| 219 |
+
}
|
| 220 |
+
},
|
| 221 |
+
onSliderEnd() {
|
| 222 |
+
// 只在用户停止拖动时触发,避免拖动过程中频繁计算
|
| 223 |
+
if (!Array.isArray(this.sliderValue) || this.sliderValue.length !== 2) return
|
| 224 |
+
|
| 225 |
+
// 清除之前的定时器
|
| 226 |
+
if (this.debounceTimer) {
|
| 227 |
+
clearTimeout(this.debounceTimer)
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// 显示计算中状态
|
| 231 |
+
this.isCalculating = true
|
| 232 |
+
|
| 233 |
+
// 添加300ms延迟,确保用户真的停止了拖动
|
| 234 |
+
this.debounceTimer = setTimeout(() => {
|
| 235 |
+
const startDate = this.allDates[this.sliderValue[0]]
|
| 236 |
+
const endDate = this.allDates[this.sliderValue[1]]
|
| 237 |
+
if (startDate && endDate) {
|
| 238 |
+
this.internalUpdate = true
|
| 239 |
+
this.datesModel = [startDate, endDate]
|
| 240 |
+
}
|
| 241 |
+
// 延迟隐藏计算中提示,让用户看到反馈
|
| 242 |
+
setTimeout(() => {
|
| 243 |
+
this.isCalculating = false
|
| 244 |
+
}, 100)
|
| 245 |
+
}, 300)
|
| 246 |
+
},
|
| 247 |
+
emitUpdate(partial){
|
| 248 |
+
const merged = { names: [], assets: [], models: [], strategies: [], ...(this.modelValue || {}), ...partial }
|
| 249 |
+
this.$emit('update:modelValue', merged)
|
| 250 |
+
},
|
| 251 |
+
selectAll(key){
|
| 252 |
+
const map = {
|
| 253 |
+
names: this.nameOptions,
|
| 254 |
+
assets: this.assetOptions,
|
| 255 |
+
models: this.modelOptions,
|
| 256 |
+
strategies: this.strategyOptions
|
| 257 |
+
}
|
| 258 |
+
const values = (map[key] || []).map(o => o.value)
|
| 259 |
+
if (key === 'names') this.namesModel = values
|
| 260 |
+
else if (key === 'assets') this.assetsModel = values
|
| 261 |
+
else if (key === 'models') this.modelsModel = values
|
| 262 |
+
else if (key === 'strategies') this.strategiesModel = values
|
| 263 |
+
},
|
| 264 |
+
clearAll(key){
|
| 265 |
+
if (key === 'names') this.namesModel = []
|
| 266 |
+
else if (key === 'assets') this.assetsModel = []
|
| 267 |
+
else if (key === 'models') this.modelsModel = []
|
| 268 |
+
else if (key === 'strategies') this.strategiesModel = []
|
| 269 |
+
},
|
| 270 |
+
clearDateRange() {
|
| 271 |
+
// 清除定时器和计算状态
|
| 272 |
+
if (this.debounceTimer) {
|
| 273 |
+
clearTimeout(this.debounceTimer)
|
| 274 |
+
this.debounceTimer = null
|
| 275 |
+
}
|
| 276 |
+
this.isCalculating = false
|
| 277 |
+
// 重置到默认起始日期 2025-08-01
|
| 278 |
+
this.initializeSlider()
|
| 279 |
+
this.datesModel = []
|
| 280 |
+
}
|
| 281 |
+
},
|
| 282 |
+
beforeUnmount() {
|
| 283 |
+
// 组件销毁时清理定时器
|
| 284 |
+
if (this.debounceTimer) {
|
| 285 |
+
clearTimeout(this.debounceTimer)
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
</script>
|
| 290 |
+
|
| 291 |
+
<style scoped>
|
| 292 |
+
.filters-compact :deep(.p-divider.p-divider-horizontal) { margin: 0.5rem 0; }
|
| 293 |
+
.filters-compact :deep(.p-button) { padding: 0.25rem 0.5rem; font-size: 0.85rem; }
|
| 294 |
+
.filters-compact :deep(.p-button .p-button-label) { font-weight: 500; }
|
| 295 |
+
.filters-compact :deep(.p-checkbox) { transform: scale(0.9); }
|
| 296 |
+
.filters-compact .opt-label { font-size: 0.9rem; line-height: 1.2; color: #111827; }
|
| 297 |
+
.date-row {
|
| 298 |
+
margin-bottom: 0.5rem;
|
| 299 |
+
padding: 0.75rem;
|
| 300 |
+
background: #f8fafc;
|
| 301 |
+
border-radius: 6px;
|
| 302 |
+
}
|
| 303 |
+
.date-slider-container {
|
| 304 |
+
padding: 0.5rem 0;
|
| 305 |
+
}
|
| 306 |
+
.date-slider-container :deep(.p-slider) {
|
| 307 |
+
background: #e2e8f0;
|
| 308 |
+
height: 4px;
|
| 309 |
+
}
|
| 310 |
+
.date-slider-container :deep(.p-slider .p-slider-range) {
|
| 311 |
+
background: var(--p-primary-color);
|
| 312 |
+
}
|
| 313 |
+
.date-slider-container :deep(.p-slider .p-slider-handle) {
|
| 314 |
+
background: var(--p-primary-color);
|
| 315 |
+
}
|
| 316 |
+
.calculating-text {
|
| 317 |
+
color: var(--p-primary-color);
|
| 318 |
+
font-size: 0.85rem;
|
| 319 |
+
}
|
| 320 |
+
</style>
|
| 321 |
+
|
| 322 |
+
|
src/components/AgentTable.vue
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<DataTable :value="rows" :rows="10" :rowsPerPageOptions="[10,25,50]" paginator scrollable scrollHeight="flex" :loading="loading" :sortMode="'multiple'" :multiSortMeta="multiSortMeta" v-model:expandedRows="expandedRows" :dataKey="'key'" @sort="onSort" @rowToggle="onRowToggle" @rowExpand="onRowExpand" :selection="selection" @update:selection="onSelectionUpdate">
|
| 3 |
+
<Column v-if="selectable" selectionMode="multiple" style="width: 3rem" />
|
| 4 |
+
<Column expander style="width: 3rem" />
|
| 5 |
+
<Column field="agent_name" header="Agent & Model & Strategy">
|
| 6 |
+
<template #body="{ data }">
|
| 7 |
+
<div>
|
| 8 |
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
| 9 |
+
<span>{{ data.agent_name }}</span>
|
| 10 |
+
<span style="font-size: 1.25rem;">{{ getRankMedal(data) }}</span>
|
| 11 |
+
</div>
|
| 12 |
+
<div style="color:#6b7280; font-size: 0.875rem;">{{ data.model }}</div>
|
| 13 |
+
<!-- <div style="color:#6b7280; font-size: 0.875rem;">{{ data.strategy_label }}</div> -->
|
| 14 |
+
</div>
|
| 15 |
+
</template>
|
| 16 |
+
</Column>
|
| 17 |
+
<!-- <Column field="asset" header="Asset"/> -->
|
| 18 |
+
<Column field="ret_with_fees" header="Return" sortable>
|
| 19 |
+
<template #body="{ data }">
|
| 20 |
+
<div>
|
| 21 |
+
<div :style="pctStyle(data.ret_with_fees)">{{ fmtSignedPct(data.ret_with_fees) }}</div>
|
| 22 |
+
<div :style="subPctStyle(data.ret_no_fees)">(No Fees: {{ fmtSignedPct(data.ret_no_fees) }})</div>
|
| 23 |
+
</div>
|
| 24 |
+
</template>
|
| 25 |
+
</Column>
|
| 26 |
+
|
| 27 |
+
<Column field="vs_bh_with_fees" header="Vs Buy & Hold" sortable>
|
| 28 |
+
<template #body="{ data }">
|
| 29 |
+
<span :style="pctStyle(data.vs_bh_with_fees)">{{ fmtSignedPct(data.vs_bh_with_fees) }}</span>
|
| 30 |
+
</template>
|
| 31 |
+
</Column>
|
| 32 |
+
|
| 33 |
+
<Column field="sharpe" header="Sharpe Ratio" sortable>
|
| 34 |
+
<template #body="{ data }">
|
| 35 |
+
{{ fmtNum(data.sharpe) }}
|
| 36 |
+
</template>
|
| 37 |
+
</Column>
|
| 38 |
+
|
| 39 |
+
<Column field="win_rate" header="Win Rate" sortable>
|
| 40 |
+
<template #body="{ data }">
|
| 41 |
+
{{ fmtPctNeutral(data.win_rate) }}
|
| 42 |
+
</template>
|
| 43 |
+
</Column>
|
| 44 |
+
<template #expansion="slotProps">
|
| 45 |
+
<ExpansionContent :rowData="slotProps.data" />
|
| 46 |
+
</template>
|
| 47 |
+
</DataTable>
|
| 48 |
+
|
| 49 |
+
</template>
|
| 50 |
+
|
| 51 |
+
<script>
|
| 52 |
+
import ExpansionContent from './ExpansionContent.vue'
|
| 53 |
+
export default {
|
| 54 |
+
name: 'AgentTable',
|
| 55 |
+
props: { rows: { type: Array, default: () => [] }, loading: { type: Boolean, default: false }, selectable: { type: Boolean, default: false }, selection: { type: Array, default: () => [] } },
|
| 56 |
+
emits: ['update:selection'],
|
| 57 |
+
components: { ExpansionContent },
|
| 58 |
+
data(){
|
| 59 |
+
return {
|
| 60 |
+
expandedRows: [],
|
| 61 |
+
multiSortMeta: [
|
| 62 |
+
{ field: 'ret_with_fees', order: -1 }
|
| 63 |
+
]
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
computed: {
|
| 67 |
+
rankedRows() {
|
| 68 |
+
// Sort rows by ret_with_fees descending to determine rank
|
| 69 |
+
return [...this.rows].sort((a, b) => {
|
| 70 |
+
const aVal = Number(a.ret_with_fees) || 0
|
| 71 |
+
const bVal = Number(b.ret_with_fees) || 0
|
| 72 |
+
return bVal - aVal
|
| 73 |
+
})
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
methods: {
|
| 77 |
+
getRankMedal(data) {
|
| 78 |
+
// Find the rank of this row based on ret_with_fees
|
| 79 |
+
const rank = this.rankedRows.findIndex(row => row.key === data.key) + 1
|
| 80 |
+
if (rank === 1) return '🥇'
|
| 81 |
+
if (rank === 2) return '🥈'
|
| 82 |
+
if (rank === 3) return '🥉'
|
| 83 |
+
return ''
|
| 84 |
+
},
|
| 85 |
+
onSelectionUpdate(val){
|
| 86 |
+
this.$emit('update:selection', Array.isArray(val) ? val : [])
|
| 87 |
+
},
|
| 88 |
+
onSort(){
|
| 89 |
+
// close all rows when sorting
|
| 90 |
+
this.expandedRows = []
|
| 91 |
+
},
|
| 92 |
+
onRowToggle(e){
|
| 93 |
+
// keep only one expanded row at a time
|
| 94 |
+
const val = e.data || e
|
| 95 |
+
if (Array.isArray(val)) {
|
| 96 |
+
// when using array mode, restrict to the last toggled row
|
| 97 |
+
this.expandedRows = val.length ? [val[val.length - 1]] : []
|
| 98 |
+
} else if (val && typeof val === 'object') {
|
| 99 |
+
// object mode; keep only the last key
|
| 100 |
+
const keys = Object.keys(val)
|
| 101 |
+
if (!keys.length) { this.expandedRows = {}; return }
|
| 102 |
+
const lastKey = keys[keys.length - 1]
|
| 103 |
+
const map = {}
|
| 104 |
+
map[lastKey] = true
|
| 105 |
+
this.expandedRows = map
|
| 106 |
+
} else {
|
| 107 |
+
this.expandedRows = []
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
onRowExpand(e){
|
| 111 |
+
// ensure only the current row is expanded
|
| 112 |
+
const row = e && e.data
|
| 113 |
+
if (!row) { this.expandedRows = []; return }
|
| 114 |
+
// DataTable may track expandedRows as array or map depending on mode
|
| 115 |
+
if (Array.isArray(this.expandedRows)) {
|
| 116 |
+
this.expandedRows = [row]
|
| 117 |
+
} else {
|
| 118 |
+
const map = {}
|
| 119 |
+
map[row.key] = true
|
| 120 |
+
this.expandedRows = map
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
fmtMoney(v){ try{ return `$${Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}` }catch{return v} },
|
| 124 |
+
fmtNum(v){ if(v==null) return '-'; return Number(v).toFixed(2) },
|
| 125 |
+
// v is always a fraction (0.12 = 12%, 1.5 = 150%). Always render with two decimals + sign
|
| 126 |
+
fmtSignedPct(v){
|
| 127 |
+
if(v==null) return '-'
|
| 128 |
+
const pct = Number(v) * 100
|
| 129 |
+
const sign = pct > 0 ? '+' : (pct < 0 ? '-' : '')
|
| 130 |
+
return `${sign}${Math.abs(pct).toFixed(2)}%`
|
| 131 |
+
},
|
| 132 |
+
// neutral percentage (no color/sign) for win rate. v is already in percentage form (0-100).
|
| 133 |
+
fmtPctNeutral(v){
|
| 134 |
+
if(v==null) return '-'
|
| 135 |
+
return `${Number(v).toFixed(2)}%`
|
| 136 |
+
},
|
| 137 |
+
pctStyle(v){
|
| 138 |
+
const val = Number(v)
|
| 139 |
+
if (val > 0) return { color: '#16a34a', fontWeight: 'bold' } // green-600
|
| 140 |
+
if (val < 0) return { color: '#dc2626', fontWeight: 'bold' } // red-600
|
| 141 |
+
return {}
|
| 142 |
+
},
|
| 143 |
+
subPctStyle(v){
|
| 144 |
+
const val = Number(v)
|
| 145 |
+
if (val > 0) return { color: '#22c55e', fontSize: '0.8rem'} // green-500
|
| 146 |
+
if (val < 0) return { color: '#ef4444', fontSize: '0.8rem'} // red-500
|
| 147 |
+
return { color: '#6b7280', fontSize: '0.8rem'} // gray-500 for neutral
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
</script>
|
| 152 |
+
|
| 153 |
+
<style scoped>
|
| 154 |
+
</style>
|
| 155 |
+
|
| 156 |
+
|
src/components/AssetsFilter.vue
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<Tabs :value="selectedAsset" @update:value="onTabChange" class="assets-filter-container">
|
| 3 |
+
<TabList>
|
| 4 |
+
<Tab
|
| 5 |
+
v-for="asset in availableAssets"
|
| 6 |
+
:key="asset.value"
|
| 7 |
+
:value="asset.value"
|
| 8 |
+
>
|
| 9 |
+
<div class="flex align-items-center gap-2">
|
| 10 |
+
<img
|
| 11 |
+
:src="getAssetImage(asset.value)"
|
| 12 |
+
:alt="asset.label"
|
| 13 |
+
class="asset-image-small"
|
| 14 |
+
/>
|
| 15 |
+
<span class="font-bold">{{ asset.label }}</span>
|
| 16 |
+
</div>
|
| 17 |
+
</Tab>
|
| 18 |
+
</TabList>
|
| 19 |
+
</Tabs>
|
| 20 |
+
</template>
|
| 21 |
+
|
| 22 |
+
<script>
|
| 23 |
+
import Tabs from 'primevue/tabs'
|
| 24 |
+
import TabList from 'primevue/tablist'
|
| 25 |
+
import Tab from 'primevue/tab'
|
| 26 |
+
import TabPanels from 'primevue/tabpanels'
|
| 27 |
+
import TabPanel from 'primevue/tabpanel'
|
| 28 |
+
|
| 29 |
+
export default {
|
| 30 |
+
name: 'AssetsFilter',
|
| 31 |
+
components: {
|
| 32 |
+
Tabs,
|
| 33 |
+
TabList,
|
| 34 |
+
Tab,
|
| 35 |
+
TabPanels,
|
| 36 |
+
TabPanel
|
| 37 |
+
},
|
| 38 |
+
props: {
|
| 39 |
+
modelValue: { type: Array, default: () => [] },
|
| 40 |
+
assetOptions: { type: Array, default: () => [] }
|
| 41 |
+
},
|
| 42 |
+
emits: ['update:modelValue'],
|
| 43 |
+
computed: {
|
| 44 |
+
availableAssets() {
|
| 45 |
+
return this.assetOptions.sort((a, b) => a.label.localeCompare(b.label)) || []
|
| 46 |
+
},
|
| 47 |
+
selectedAsset() {
|
| 48 |
+
const selected = this.modelValue || []
|
| 49 |
+
return selected.length > 0 ? selected[0] : (this.availableAssets[0]?.value || null)
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
methods: {
|
| 53 |
+
getAssetImage(assetCode) {
|
| 54 |
+
try {
|
| 55 |
+
return new URL(`../assets/images/assets_images/${assetCode}.png`, import.meta.url).href
|
| 56 |
+
} catch (e) {
|
| 57 |
+
return ''
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
getAssetDescription(assetCode) {
|
| 61 |
+
const descriptions = {
|
| 62 |
+
BTC: 'Bitcoin - Leading cryptocurrency',
|
| 63 |
+
ETH: 'Ethereum - Smart contract platform',
|
| 64 |
+
TSLA: 'Tesla - Electric vehicles & clean energy',
|
| 65 |
+
MSFT: 'Microsoft - Technology corporation',
|
| 66 |
+
MRNA: 'Moderna - Biotechnology company',
|
| 67 |
+
BMRN: 'BioMarin - Biopharmaceutical company',
|
| 68 |
+
AAPL: 'Apple - Technology and consumer electronics'
|
| 69 |
+
}
|
| 70 |
+
return descriptions[assetCode] || `Trading asset: ${assetCode}`
|
| 71 |
+
},
|
| 72 |
+
onTabChange(newValue) {
|
| 73 |
+
this.$emit('update:modelValue', [newValue])
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
</script>
|
| 78 |
+
|
| 79 |
+
<style scoped>
|
| 80 |
+
.assets-filter-container {
|
| 81 |
+
width: 100%;
|
| 82 |
+
overflow-x: auto;
|
| 83 |
+
overflow-y: hidden;
|
| 84 |
+
}
|
| 85 |
+
.asset-image-small {
|
| 86 |
+
width: 20px;
|
| 87 |
+
height: 20px;
|
| 88 |
+
object-fit: contain;
|
| 89 |
+
flex-shrink: 0;
|
| 90 |
+
}
|
| 91 |
+
</style>
|
src/components/CompareChart.vue
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="chart-container">
|
| 3 |
+
<canvas ref="canvas"></canvas>
|
| 4 |
+
</div>
|
| 5 |
+
</template>
|
| 6 |
+
|
| 7 |
+
<script>
|
| 8 |
+
import { getAllDecisions } from '../lib/dataCache'
|
| 9 |
+
import { readAllRawDecisions } from '../lib/idb'
|
| 10 |
+
import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
|
| 11 |
+
import { STRATEGIES } from '../lib/strategies'
|
| 12 |
+
import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
|
| 13 |
+
import { Chart, LineElement, PointElement, LinearScale, CategoryScale, LineController, Legend, Tooltip } from 'chart.js'
|
| 14 |
+
import { getStrategyColor } from '../lib/chartColors'
|
| 15 |
+
|
| 16 |
+
const vLinePlugin = {
|
| 17 |
+
id: 'vLinePlugin',
|
| 18 |
+
afterDatasetsDraw(chart, args, pluginOptions) {
|
| 19 |
+
const active = (typeof chart.getActiveElements === 'function') ? chart.getActiveElements() : (chart.tooltip && chart.tooltip._active) || []
|
| 20 |
+
if (!active || !active.length) return
|
| 21 |
+
const { datasetIndex, index } = active[0]
|
| 22 |
+
const meta = chart.getDatasetMeta(datasetIndex)
|
| 23 |
+
const pt = meta && meta.data && meta.data[index]
|
| 24 |
+
if (!pt) return
|
| 25 |
+
const x = pt.x
|
| 26 |
+
const { top, bottom } = chart.chartArea
|
| 27 |
+
const ctx = chart.ctx
|
| 28 |
+
ctx.save()
|
| 29 |
+
ctx.beginPath()
|
| 30 |
+
ctx.moveTo(x, top)
|
| 31 |
+
ctx.lineTo(x, bottom)
|
| 32 |
+
ctx.lineWidth = (pluginOptions && pluginOptions.lineWidth) || 1
|
| 33 |
+
ctx.strokeStyle = (pluginOptions && pluginOptions.color) || 'rgba(0,0,0,0.35)'
|
| 34 |
+
ctx.setLineDash((pluginOptions && pluginOptions.dash) || [4, 4])
|
| 35 |
+
ctx.stroke()
|
| 36 |
+
ctx.restore()
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
Chart.register(LineElement, PointElement, LinearScale, CategoryScale, LineController, Legend, Tooltip, vLinePlugin)
|
| 41 |
+
|
| 42 |
+
export default {
|
| 43 |
+
name: 'CompareChart',
|
| 44 |
+
props: {
|
| 45 |
+
selected: { type: Array, default: () => [] },
|
| 46 |
+
visible: { type: Boolean, default: false }
|
| 47 |
+
},
|
| 48 |
+
data(){
|
| 49 |
+
return { chart: null, datasets: [], labels: [] }
|
| 50 |
+
},
|
| 51 |
+
watch: {
|
| 52 |
+
visible(v){ if (v) { this.$nextTick(() => this.buildAndDraw()) } },
|
| 53 |
+
selected: { deep: true, handler(){ this.buildAndDraw() } }
|
| 54 |
+
},
|
| 55 |
+
mounted(){ this.$nextTick(() => this.buildAndDraw()) },
|
| 56 |
+
beforeUnmount(){ try{ this.chart && this.chart.destroy() }catch(_){} },
|
| 57 |
+
methods: {
|
| 58 |
+
async buildAndDraw(){
|
| 59 |
+
if (!this.visible) return
|
| 60 |
+
await this.rebuildDatasetsFromSelection()
|
| 61 |
+
this.draw()
|
| 62 |
+
},
|
| 63 |
+
async getAll(){
|
| 64 |
+
let all = getAllDecisions() || []
|
| 65 |
+
if (!all.length) {
|
| 66 |
+
try { const cached = await readAllRawDecisions(); if (cached && cached.length) all = cached } catch(_) {}
|
| 67 |
+
}
|
| 68 |
+
return all
|
| 69 |
+
},
|
| 70 |
+
async rebuildDatasetsFromSelection(){
|
| 71 |
+
const selected = Array.isArray(this.selected) ? this.selected : []
|
| 72 |
+
const all = await this.getAll()
|
| 73 |
+
const groupKeyToSeq = new Map()
|
| 74 |
+
for (const sel of selected) {
|
| 75 |
+
const agent = sel.agent_name
|
| 76 |
+
const asset = sel.asset
|
| 77 |
+
const model = sel.model
|
| 78 |
+
const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
|
| 79 |
+
let seq = []
|
| 80 |
+
if (ids.length) seq = all.filter(r => ids.includes(r.id))
|
| 81 |
+
else seq = all.filter(r => r.agent_name === agent && r.asset === asset && r.model === model)
|
| 82 |
+
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 83 |
+
const isCrypto = asset === 'BTC' || asset === 'ETH'
|
| 84 |
+
const filtered = isCrypto ? seq : await filterRowsToNyseTradingDays(seq)
|
| 85 |
+
groupKeyToSeq.set(`${agent}|${asset}|${model}`, filtered)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const ds = []
|
| 89 |
+
const keys = Array.from(groupKeyToSeq.keys())
|
| 90 |
+
// labels from first sequence
|
| 91 |
+
this.labels = []
|
| 92 |
+
if (keys.length) this.labels = (groupKeyToSeq.get(keys[0]) || []).map(s => s.date)
|
| 93 |
+
|
| 94 |
+
// 按 agent 分组以应用颜色规则
|
| 95 |
+
const agentGroups = new Map() // agent -> [selection items]
|
| 96 |
+
const selectedAssets = new Set()
|
| 97 |
+
|
| 98 |
+
for (const sel of selected) {
|
| 99 |
+
const gk = `${sel.agent_name}|${sel.asset}|${sel.model}`
|
| 100 |
+
const seq = groupKeyToSeq.get(gk) || []
|
| 101 |
+
if (!seq.length) continue
|
| 102 |
+
|
| 103 |
+
selectedAssets.add(sel.asset)
|
| 104 |
+
const agent = sel.agent_name
|
| 105 |
+
if (!agentGroups.has(agent)) {
|
| 106 |
+
agentGroups.set(agent, [])
|
| 107 |
+
}
|
| 108 |
+
agentGroups.get(agent).push({ sel, seq })
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// 为每个 agent 的策略生成系列,同一 agent 使用相同色调
|
| 112 |
+
for (const [agent, items] of agentGroups.entries()) {
|
| 113 |
+
items.forEach((item, index) => {
|
| 114 |
+
const { sel, seq } = item
|
| 115 |
+
const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || {
|
| 116 |
+
strategy: 'long_short',
|
| 117 |
+
tradingMode: 'normal',
|
| 118 |
+
fee: 0.0005,
|
| 119 |
+
label: 'Selected'
|
| 120 |
+
}
|
| 121 |
+
const series = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode)
|
| 122 |
+
if (!series.length) return
|
| 123 |
+
|
| 124 |
+
// 同一 agent 的策略使用相同色调,通过亮度区分
|
| 125 |
+
const strategyColor = getStrategyColor(agent, false, index)
|
| 126 |
+
|
| 127 |
+
ds.push({
|
| 128 |
+
label: `${sel.agent_name}|${sel.asset}|${sel.model}|${cfg.label || sel.strategy || 'Strategy'}`,
|
| 129 |
+
data: this.labels.length ? series.slice(0, this.labels.length) : series,
|
| 130 |
+
borderColor: strategyColor,
|
| 131 |
+
pointRadius: 0,
|
| 132 |
+
tension: 0.15
|
| 133 |
+
})
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// 为每个资产添加 baseline,统一使用黑色实线
|
| 138 |
+
const seenAsset = new Set()
|
| 139 |
+
for (const gk of keys) {
|
| 140 |
+
const asset = gk.split('|')[1]
|
| 141 |
+
if (!selectedAssets.has(asset)) continue
|
| 142 |
+
if (seenAsset.has(asset)) continue
|
| 143 |
+
seenAsset.add(asset)
|
| 144 |
+
const seq = groupKeyToSeq.get(gk) || []
|
| 145 |
+
const bh = computeBuyHoldEquity(seq, 100000)
|
| 146 |
+
if (!bh.length) continue
|
| 147 |
+
|
| 148 |
+
// baseline 统一使用黑色实线
|
| 149 |
+
const baselineColor = getStrategyColor('', true, 0)
|
| 150 |
+
|
| 151 |
+
ds.push({
|
| 152 |
+
label: `${asset} · Buy&Hold`,
|
| 153 |
+
data: this.labels.length ? bh.slice(0, this.labels.length) : bh,
|
| 154 |
+
borderColor: baselineColor,
|
| 155 |
+
borderWidth: 1.5,
|
| 156 |
+
pointRadius: 0,
|
| 157 |
+
tension: 0
|
| 158 |
+
})
|
| 159 |
+
}
|
| 160 |
+
this.datasets = ds
|
| 161 |
+
},
|
| 162 |
+
draw(){
|
| 163 |
+
if (!this.$refs.canvas) return
|
| 164 |
+
this.$nextTick(() => {
|
| 165 |
+
if (!this.$refs.canvas) return
|
| 166 |
+
try{ this.chart && this.chart.destroy() }catch(_){}
|
| 167 |
+
const parent = this.$refs.canvas.parentElement
|
| 168 |
+
if (parent) {
|
| 169 |
+
if (!parent.clientHeight || parent.clientHeight < 50) { parent.style.minHeight = '560px'; parent.style.height = '560px' }
|
| 170 |
+
const w = parent.clientWidth || 800
|
| 171 |
+
const h = parent.clientHeight || 560
|
| 172 |
+
this.$refs.canvas.style.width = '100%'
|
| 173 |
+
this.$refs.canvas.style.height = '100%'
|
| 174 |
+
this.$refs.canvas.width = w
|
| 175 |
+
this.$refs.canvas.height = h
|
| 176 |
+
}
|
| 177 |
+
const labels = this.labels.length ? this.labels : Array.from({ length: Math.max(0, ...this.datasets.map(d => (Array.isArray(d.data) ? d.data.length : 0))) }, (_, i) => `${i+1}`)
|
| 178 |
+
const allValues = this.datasets.flatMap(d => (Array.isArray(d.data) ? d.data : []))
|
| 179 |
+
const minV = allValues.length ? Math.min(...allValues) : 0
|
| 180 |
+
const maxV = allValues.length ? Math.max(...allValues) : 1
|
| 181 |
+
const pad = (maxV - minV) * 0.1 || 1000
|
| 182 |
+
const yMin = minV - pad
|
| 183 |
+
const yMax = maxV + pad
|
| 184 |
+
const ctx = this.$refs.canvas.getContext('2d')
|
| 185 |
+
this.chart = new Chart(ctx, {
|
| 186 |
+
type: 'line',
|
| 187 |
+
data: { labels, datasets: this.datasets },
|
| 188 |
+
options: {
|
| 189 |
+
responsive: true,
|
| 190 |
+
maintainAspectRatio: false,
|
| 191 |
+
animation: false,
|
| 192 |
+
interaction: { mode: 'index', intersect: false },
|
| 193 |
+
plugins: {
|
| 194 |
+
legend: { display: true, position: 'left', labels: { usePointStyle: true, boxWidth: 8 } },
|
| 195 |
+
vLinePlugin: { color: 'rgba(0,0,0,0.35)', lineWidth: 1, dash: [4,4] }
|
| 196 |
+
},
|
| 197 |
+
scales: { x: { type: 'category', ticks: { autoSkip: true, maxTicksLimit: 10 } }, y: { min: yMin, max: yMax } }
|
| 198 |
+
}
|
| 199 |
+
})
|
| 200 |
+
})
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
</script>
|
| 205 |
+
|
| 206 |
+
<style scoped>
|
| 207 |
+
.chart-container { position: relative; height: 560px; display: flex; }
|
| 208 |
+
.chart-container > canvas { width: 100% !important; height: 100% !important; }
|
| 209 |
+
</style>
|
| 210 |
+
|
| 211 |
+
|
src/components/ExpansionContent.vue
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="p-4" style="border: 1px solid #e5e7eb; border-radius: 8px; background: #fafafa;">
|
| 3 |
+
<Panel class="mb-3" header="Details">
|
| 4 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
| 5 |
+
<div>
|
| 6 |
+
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Trading Days</div>
|
| 7 |
+
<div style="font-weight: 600;">{{ rowData.trading_days || 0 }}</div>
|
| 8 |
+
</div>
|
| 9 |
+
<div>
|
| 10 |
+
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Start Date</div>
|
| 11 |
+
<div style="font-weight: 600;">{{ rowData.start_date || '-' }}</div>
|
| 12 |
+
</div>
|
| 13 |
+
<div>
|
| 14 |
+
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">End Date</div>
|
| 15 |
+
<div style="font-weight: 600;">{{ rowData.end_date || '-' }}</div>
|
| 16 |
+
</div>
|
| 17 |
+
<div>
|
| 18 |
+
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Closed Days</div>
|
| 19 |
+
<div style="font-weight: 600;">{{ rowData.closed_date || 0 }}</div>
|
| 20 |
+
</div>
|
| 21 |
+
<div>
|
| 22 |
+
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Final Balance</div>
|
| 23 |
+
<div style="font-weight: 600;">{{ fmtMoney(rowData.balance) }}</div>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</Panel>
|
| 27 |
+
<Panel header="Performance">
|
| 28 |
+
<PerformanceChart :data="rowData" />
|
| 29 |
+
</Panel>
|
| 30 |
+
<div class="flex justify-content-end mt-3">
|
| 31 |
+
<Button size="small" rounded icon="pi pi-search" label="Decisions View" @click="decisionsVisible = true" />
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
<Dialog v-model:visible="decisionsVisible" modal :header="`${rowData.agent_name} (${rowData.asset}) - ${rowData.model}`" :style="{ width: '90vw', maxWidth: '1200px' }">
|
| 35 |
+
<div>
|
| 36 |
+
<div v-if="decisionsLoading" class="flex justify-content-center p-4">
|
| 37 |
+
<ProgressSpinner style="width:40px;height:40px" strokeWidth="4" />
|
| 38 |
+
</div>
|
| 39 |
+
<div v-else class="decisions-table-wrapper">
|
| 40 |
+
<DataTable :value="decisionsRows" :rows="25" :rowsPerPageOptions="[25,50]" paginator :sortField="'date'" :sortOrder="1">
|
| 41 |
+
<Column field="date" header="Date" sortable />
|
| 42 |
+
<Column field="action" header="Action">
|
| 43 |
+
<template #body="{ data }">
|
| 44 |
+
<Tag :value="data.action" :severity="getTagColor(data.action)" rounded/>
|
| 45 |
+
</template>
|
| 46 |
+
</Column>
|
| 47 |
+
<Column field="price" header="Price">
|
| 48 |
+
<template #body="{ data }">{{ Number(data.price).toFixed(2) }}</template>
|
| 49 |
+
</Column>
|
| 50 |
+
<Column field="equity" header="Equity (w/ fees)">
|
| 51 |
+
<template #body="{ data }">{{ fmtMoney(data.equity) }}</template>
|
| 52 |
+
</Column>
|
| 53 |
+
<Column field="position" header="Position">
|
| 54 |
+
<template #body="{ data }">
|
| 55 |
+
<Tag :value="data.position" :severity="getTagColor(data.position)" rounded/>
|
| 56 |
+
</template>
|
| 57 |
+
</Column>
|
| 58 |
+
<Column field="sentiment" header="Sentiment" />
|
| 59 |
+
</DataTable>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</Dialog>
|
| 63 |
+
</template>
|
| 64 |
+
|
| 65 |
+
<script>
|
| 66 |
+
import Panel from 'primevue/panel'
|
| 67 |
+
import Dialog from 'primevue/dialog'
|
| 68 |
+
import Tag from 'primevue/tag'
|
| 69 |
+
import PerformanceChart from './PerformanceChart.vue'
|
| 70 |
+
import { getAllDecisions } from '../lib/dataCache'
|
| 71 |
+
import { readAllRawDecisions } from '../lib/idb'
|
| 72 |
+
import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
|
| 73 |
+
import { STRATEGIES } from '../lib/strategies'
|
| 74 |
+
import { computeStrategyEquity } from '../lib/perf'
|
| 75 |
+
export default {
|
| 76 |
+
name: 'ExpansionContent',
|
| 77 |
+
components: {
|
| 78 |
+
Panel,
|
| 79 |
+
PerformanceChart,
|
| 80 |
+
Dialog,
|
| 81 |
+
Tag
|
| 82 |
+
},
|
| 83 |
+
props: {
|
| 84 |
+
rowData: { type: Object, required: true }
|
| 85 |
+
},
|
| 86 |
+
data(){
|
| 87 |
+
return {
|
| 88 |
+
decisionsVisible: false,
|
| 89 |
+
decisionsLoading: false,
|
| 90 |
+
decisionsRows: []
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
watch: {
|
| 94 |
+
decisionsVisible: {
|
| 95 |
+
async handler(v){ if (v) await this.loadDecisions() }
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
methods: {
|
| 99 |
+
fmtMoney(v){ try{ return `$${Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}` }catch{return v} },
|
| 100 |
+
getSeqFromIds(row){
|
| 101 |
+
const all = getAllDecisions() || []
|
| 102 |
+
const ids = row && (row.decision_ids || row.ids || [])
|
| 103 |
+
let seq = []
|
| 104 |
+
if (ids && ids.length) {
|
| 105 |
+
seq = all.filter(r => ids.includes(r.id))
|
| 106 |
+
} else if (row) {
|
| 107 |
+
// Only fallback to full data if no decision_ids were provided at all
|
| 108 |
+
seq = all.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model)
|
| 109 |
+
}
|
| 110 |
+
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 111 |
+
return seq
|
| 112 |
+
},
|
| 113 |
+
async getSeqWithFallback(row){
|
| 114 |
+
let seq = this.getSeqFromIds(row)
|
| 115 |
+
if (seq.length) return seq
|
| 116 |
+
try {
|
| 117 |
+
const all = await readAllRawDecisions()
|
| 118 |
+
const ids = row && (row.decision_ids || row.ids || [])
|
| 119 |
+
if (ids && ids.length) {
|
| 120 |
+
seq = all.filter(r => ids.includes(r.id))
|
| 121 |
+
} else if (row) {
|
| 122 |
+
// Only fallback to full data if no decision_ids were provided at all
|
| 123 |
+
seq = all.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model)
|
| 124 |
+
}
|
| 125 |
+
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 126 |
+
} catch(_) {}
|
| 127 |
+
return seq
|
| 128 |
+
},
|
| 129 |
+
computePositionSeries(rows, strategyCfg){
|
| 130 |
+
const data = (rows || []).filter(r => r && r.date && r.price != null).sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 131 |
+
if (!data.length) return []
|
| 132 |
+
let position = 'FLAT'
|
| 133 |
+
let entryPrice = 0
|
| 134 |
+
const positions = []
|
| 135 |
+
for (let i = 0; i < data.length; i++) {
|
| 136 |
+
const price = Number(data[i].price)
|
| 137 |
+
const action = String(data[i].recommended_action || 'HOLD').toUpperCase()
|
| 138 |
+
if ((strategyCfg.tradingMode || 'normal') === 'normal') {
|
| 139 |
+
if (action === 'BUY') {
|
| 140 |
+
if (position === 'FLAT') { position = 'LONG'; entryPrice = price }
|
| 141 |
+
else if (position === 'SHORT') { position = 'LONG'; entryPrice = price }
|
| 142 |
+
} else if (action === 'SELL') {
|
| 143 |
+
if (position === 'LONG') { position = 'FLAT'; entryPrice = 0 }
|
| 144 |
+
else if (position === 'FLAT' && (strategyCfg.strategy || 'long_short') === 'long_short') { position = 'SHORT'; entryPrice = price }
|
| 145 |
+
}
|
| 146 |
+
} else { // aggressive
|
| 147 |
+
if (action === 'HOLD') {
|
| 148 |
+
if (position === 'LONG' || position === 'SHORT') { position = 'FLAT'; entryPrice = 0 }
|
| 149 |
+
} else if (action === 'BUY') {
|
| 150 |
+
if (position === 'SHORT') { position = 'FLAT'; entryPrice = 0 }
|
| 151 |
+
if (position === 'FLAT') { position = 'LONG'; entryPrice = price }
|
| 152 |
+
} else if (action === 'SELL') {
|
| 153 |
+
if (position === 'LONG') { position = 'FLAT'; entryPrice = 0 }
|
| 154 |
+
if (position === 'FLAT' && (strategyCfg.strategy || 'long_short') === 'long_short') { position = 'SHORT'; entryPrice = price }
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
positions.push(position === 'LONG' ? 'Long' : (position === 'SHORT' ? 'Short' : 'Flat'))
|
| 158 |
+
}
|
| 159 |
+
// force last flat only affects capital; positions array already recorded daily state
|
| 160 |
+
return positions
|
| 161 |
+
},
|
| 162 |
+
async loadDecisions(){
|
| 163 |
+
this.decisionsLoading = true
|
| 164 |
+
try {
|
| 165 |
+
const row = this.rowData
|
| 166 |
+
const rawSeq = await this.getSeqWithFallback(row)
|
| 167 |
+
const isCrypto = row.asset === 'BTC' || row.asset === 'ETH'
|
| 168 |
+
const seq = isCrypto ? rawSeq : (await filterRowsToNyseTradingDays(rawSeq) || [])
|
| 169 |
+
if (!seq.length) { this.decisionsRows = []; return }
|
| 170 |
+
const strategyCfg = (STRATEGIES || []).find(s => s.id === row.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 }
|
| 171 |
+
const equitySeries = computeStrategyEquity(seq, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode)
|
| 172 |
+
const positionSeries = this.computePositionSeries(seq, strategyCfg)
|
| 173 |
+
const n = Math.min(seq.length, equitySeries.length, positionSeries.length)
|
| 174 |
+
const rows = []
|
| 175 |
+
for (let i = 0; i < n; i++) {
|
| 176 |
+
const r = seq[i]
|
| 177 |
+
rows.push({
|
| 178 |
+
date: r.date,
|
| 179 |
+
action: String(r.recommended_action || 'HOLD').toUpperCase(),
|
| 180 |
+
price: r.price,
|
| 181 |
+
equity: equitySeries[i],
|
| 182 |
+
position: positionSeries[i],
|
| 183 |
+
sentiment: r.sentiment
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
this.decisionsRows = rows
|
| 187 |
+
} finally {
|
| 188 |
+
this.decisionsLoading = false
|
| 189 |
+
}
|
| 190 |
+
},
|
| 191 |
+
getTagColor(action) {
|
| 192 |
+
const a = String(action || '').trim().toUpperCase()
|
| 193 |
+
// Actions
|
| 194 |
+
if (a === 'BUY') return 'success'
|
| 195 |
+
if (a === 'SELL') return 'danger'
|
| 196 |
+
if (a === 'HOLD') return 'secondary'
|
| 197 |
+
// Positions
|
| 198 |
+
if (a === 'LONG') return 'success'
|
| 199 |
+
if (a === 'SHORT') return 'danger'
|
| 200 |
+
if (a === 'FLAT') return 'secondary'
|
| 201 |
+
return 'secondary'
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
</script>
|
| 207 |
+
|
| 208 |
+
<style scoped>
|
| 209 |
+
.decisions-table-wrapper {
|
| 210 |
+
overflow-x: auto;
|
| 211 |
+
overflow-y: visible;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* 确保在小屏幕上表格可以滚动 */
|
| 215 |
+
@media (max-width: 768px) {
|
| 216 |
+
.decisions-table-wrapper {
|
| 217 |
+
max-width: 100%;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.decisions-table-wrapper :deep(.p-datatable) {
|
| 221 |
+
min-width: 600px;
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
</style>
|
src/components/Footer.vue
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="page-header">
|
| 3 |
+
<div class="logos-container">
|
| 4 |
+
<span class="collaborators-text">Collaborators:</span>
|
| 5 |
+
<div class="logo-item">
|
| 6 |
+
<img src="../assets/images/companies_images/logofinai.png" alt="The Fin AI" class="logo-image" />
|
| 7 |
+
</div>
|
| 8 |
+
<div class="logo-item">
|
| 9 |
+
<img src="../assets/images/companies_images/nactemlogo.png" alt="NaCTeM" class="logo-image" />
|
| 10 |
+
</div>
|
| 11 |
+
<div class="logo-item">
|
| 12 |
+
<img src="../assets/images/companies_images/paalai_logo.png" alt="PAAL AI" class="logo-image" />
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
</template>
|
| 17 |
+
|
| 18 |
+
<style scoped>
|
| 19 |
+
.page-header {
|
| 20 |
+
position: fixed;
|
| 21 |
+
bottom: 0;
|
| 22 |
+
left: 0;
|
| 23 |
+
right: 0;
|
| 24 |
+
display: flex;
|
| 25 |
+
flex-direction: row;
|
| 26 |
+
align-items: center;
|
| 27 |
+
justify-content: center;
|
| 28 |
+
gap: 0.5rem;
|
| 29 |
+
padding: 0.75rem 1rem;
|
| 30 |
+
background: linear-gradient(135deg, #f8f9fb 0%, #ffffff 100%);
|
| 31 |
+
border-top: 1px solid #e5e7eb;
|
| 32 |
+
z-index: 100;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.logos-container {
|
| 36 |
+
display: flex;
|
| 37 |
+
align-items: center;
|
| 38 |
+
justify-content: center;
|
| 39 |
+
gap: 1rem;
|
| 40 |
+
flex-wrap: wrap;
|
| 41 |
+
margin: 0;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.collaborators-text {
|
| 45 |
+
color: #6b7280;
|
| 46 |
+
font-size: 0.95rem;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.logo-item {
|
| 50 |
+
flex-shrink: 0;
|
| 51 |
+
width: 67px;
|
| 52 |
+
height: 23px;
|
| 53 |
+
display: flex;
|
| 54 |
+
align-items: center;
|
| 55 |
+
justify-content: center;
|
| 56 |
+
padding: 0.15rem;
|
| 57 |
+
transition: transform 0.2s ease;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.logo-item:hover {
|
| 61 |
+
transform: translateY(-2px);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.logo-image {
|
| 65 |
+
max-width: 100%;
|
| 66 |
+
max-height: 100%;
|
| 67 |
+
object-fit: contain;
|
| 68 |
+
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* 响应式设计 */
|
| 72 |
+
@media (max-width: 768px) {
|
| 73 |
+
.page-header {
|
| 74 |
+
padding: 0.5rem 0.75rem;
|
| 75 |
+
gap: 0.35rem;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.logos-container {
|
| 79 |
+
gap: 0.75rem;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.logo-item {
|
| 83 |
+
width: 54px;
|
| 84 |
+
height: 20px;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
@media (max-width: 480px) {
|
| 89 |
+
.logos-container {
|
| 90 |
+
gap: 0.5rem;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.logo-item {
|
| 94 |
+
width: 47px;
|
| 95 |
+
height: 17px;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
</style>
|
src/components/Header.vue
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="page-header">
|
| 3 |
+
<div class="logos-container">
|
| 4 |
+
<div class="logo-item">
|
| 5 |
+
<img src="../assets/images/companies_images/deepkin_logo.png" alt="DeepKin" class="logo-image" @click="navigateTo('/')"/>
|
| 6 |
+
</div>
|
| 7 |
+
</div>
|
| 8 |
+
<div class="menu-container">
|
| 9 |
+
<span class="menu-item" @click="navigateTo('/live')">Live</span>
|
| 10 |
+
<span class="menu-item" @click="navigateTo('/leadboard')">Leadboard</span>
|
| 11 |
+
<span class="menu-item" @click="navigateTo('/add-asset')">Agent Arena</span>
|
| 12 |
+
</div>
|
| 13 |
+
</div>
|
| 14 |
+
</template>
|
| 15 |
+
|
| 16 |
+
<script>
|
| 17 |
+
export default {
|
| 18 |
+
methods: {
|
| 19 |
+
navigateTo(path) {
|
| 20 |
+
this.$router.push(path);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
</script>
|
| 25 |
+
<style scoped>
|
| 26 |
+
.page-header {
|
| 27 |
+
display: flex;
|
| 28 |
+
flex-direction: row;
|
| 29 |
+
align-items: center;
|
| 30 |
+
justify-content: space-between;
|
| 31 |
+
gap: 0.5rem;
|
| 32 |
+
padding: 0.75rem 1rem;
|
| 33 |
+
background: linear-gradient(135deg, #f8f9fb 0%, #ffffff 100%);
|
| 34 |
+
border-bottom: 2px solid #e5e7eb;
|
| 35 |
+
margin-bottom: 1rem;
|
| 36 |
+
}
|
| 37 |
+
.menu-container {
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: row;
|
| 40 |
+
align-items: center;
|
| 41 |
+
justify-content: center;
|
| 42 |
+
gap: 2rem;
|
| 43 |
+
margin-right: 2rem;
|
| 44 |
+
}
|
| 45 |
+
.menu-item {
|
| 46 |
+
cursor: pointer;
|
| 47 |
+
font-size: 1.2rem;
|
| 48 |
+
font-weight: 600;
|
| 49 |
+
color: #1f1f33;
|
| 50 |
+
}
|
| 51 |
+
.menu-item:hover {
|
| 52 |
+
color: #6b7280;
|
| 53 |
+
}
|
| 54 |
+
.title-container {
|
| 55 |
+
text-align: center;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
.logos-container {
|
| 60 |
+
display: flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
justify-content: flex-start;
|
| 63 |
+
gap: 1rem;
|
| 64 |
+
flex-wrap: wrap;
|
| 65 |
+
margin: 0;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.logo-item {
|
| 69 |
+
flex-shrink: 0;
|
| 70 |
+
width: 100px;
|
| 71 |
+
height: 40px;
|
| 72 |
+
display: flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
justify-content: center;
|
| 75 |
+
padding: 0.15rem;
|
| 76 |
+
transition: transform 0.2s ease;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.logo-item:hover {
|
| 80 |
+
transform: translateY(-2px);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.logo-image {
|
| 84 |
+
max-width: 100%;
|
| 85 |
+
max-height: 100%;
|
| 86 |
+
object-fit: contain;
|
| 87 |
+
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
@media (max-width: 768px) {
|
| 91 |
+
.page-header {
|
| 92 |
+
padding: 0.5rem 0.75rem;
|
| 93 |
+
gap: 0.35rem;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.main-title {
|
| 97 |
+
font-size: 1.75rem;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.logos-container {
|
| 101 |
+
gap: 0.75rem;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.logo-item {
|
| 105 |
+
width: 54px;
|
| 106 |
+
height: 20px;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
@media (max-width: 480px) {
|
| 111 |
+
.logos-container {
|
| 112 |
+
gap: 0.5rem;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.logo-item {
|
| 116 |
+
width: 47px;
|
| 117 |
+
height: 17px;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
</style>
|
src/components/PerformanceChart.vue
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div style="position: relative;">
|
| 3 |
+
<canvas ref="perfCanvas" height="280"></canvas>
|
| 4 |
+
<div v-if="isZoomed" style="position: absolute; top: 10px; right: 10px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; z-index: 10;">
|
| 5 |
+
<button
|
| 6 |
+
@click="resetZoom"
|
| 7 |
+
style="padding: 6px 12px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"
|
| 8 |
+
@mouseover="$event.target.style.background='#2563eb'"
|
| 9 |
+
@mouseout="$event.target.style.background='#3b82f6'"
|
| 10 |
+
>
|
| 11 |
+
Reset Zoom
|
| 12 |
+
</button>
|
| 13 |
+
<span style="font-size: 11px; color: #64748b; background: rgba(255,255,255,0.95); padding: 4px 8px; border-radius: 3px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
| 14 |
+
Drag to zoom | Shift+Drag to pan
|
| 15 |
+
</span>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
</template>
|
| 19 |
+
|
| 20 |
+
<script>
|
| 21 |
+
import { getAllDecisions } from '../lib/dataCache'
|
| 22 |
+
import { STRATEGIES } from '../lib/strategies'
|
| 23 |
+
import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
|
| 24 |
+
import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
|
| 25 |
+
import { Chart, LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip } from 'chart.js'
|
| 26 |
+
import zoomPlugin from 'chartjs-plugin-zoom'
|
| 27 |
+
|
| 28 |
+
// vertical crosshair plugin
|
| 29 |
+
const vLinePlugin = {
|
| 30 |
+
id: 'vLinePlugin',
|
| 31 |
+
afterDatasetsDraw(chart, args, pluginOptions) {
|
| 32 |
+
const active = (typeof chart.getActiveElements === 'function') ? chart.getActiveElements() : (chart.tooltip && chart.tooltip._active) || []
|
| 33 |
+
if (!active || !active.length) return
|
| 34 |
+
const { datasetIndex, index } = active[0]
|
| 35 |
+
const meta = chart.getDatasetMeta(datasetIndex)
|
| 36 |
+
const pt = meta && meta.data && meta.data[index]
|
| 37 |
+
if (!pt) return
|
| 38 |
+
const x = pt.x
|
| 39 |
+
const { top, bottom } = chart.chartArea
|
| 40 |
+
const ctx = chart.ctx
|
| 41 |
+
ctx.save()
|
| 42 |
+
ctx.beginPath()
|
| 43 |
+
ctx.moveTo(x, top)
|
| 44 |
+
ctx.lineTo(x, bottom)
|
| 45 |
+
ctx.lineWidth = (pluginOptions && pluginOptions.lineWidth) || 1
|
| 46 |
+
ctx.strokeStyle = (pluginOptions && pluginOptions.color) || 'rgba(0,0,0,0.35)'
|
| 47 |
+
ctx.setLineDash((pluginOptions && pluginOptions.dash) || [4, 4])
|
| 48 |
+
ctx.stroke()
|
| 49 |
+
ctx.restore()
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
Chart.register(LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip, vLinePlugin, zoomPlugin)
|
| 54 |
+
|
| 55 |
+
export default {
|
| 56 |
+
name: 'PerformanceChart',
|
| 57 |
+
props: {
|
| 58 |
+
data: { type: Object, required: true }
|
| 59 |
+
},
|
| 60 |
+
data(){
|
| 61 |
+
return {
|
| 62 |
+
chart: null,
|
| 63 |
+
isZoomed: false
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
watch: {
|
| 67 |
+
data: {
|
| 68 |
+
handler(){ this.draw() }, immediate: true, deep: true
|
| 69 |
+
}
|
| 70 |
+
},
|
| 71 |
+
mounted(){ this.$nextTick(() => this.draw()) },
|
| 72 |
+
beforeUnmount(){ try{ this.chart && this.chart.destroy() }catch(_){} },
|
| 73 |
+
methods: {
|
| 74 |
+
getSeqFromIds(row){
|
| 75 |
+
const all = getAllDecisions() || []
|
| 76 |
+
const ids = row && (row.decision_ids || row.ids || [])
|
| 77 |
+
let seq = []
|
| 78 |
+
if (ids && ids.length) {
|
| 79 |
+
seq = all.filter(r => ids.includes(r.id))
|
| 80 |
+
} else if (row) {
|
| 81 |
+
// Only fallback to full data if no decision_ids were provided at all
|
| 82 |
+
seq = all.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model)
|
| 83 |
+
}
|
| 84 |
+
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 85 |
+
return seq
|
| 86 |
+
},
|
| 87 |
+
fmtMoney(v){ try{ return `$${Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}` }catch{return v} },
|
| 88 |
+
async draw(){
|
| 89 |
+
if (!this.$refs.perfCanvas || !this.data) return
|
| 90 |
+
const row = this.data
|
| 91 |
+
const rawSeq = this.getSeqFromIds(row)
|
| 92 |
+
const isCrypto = row.asset === 'BTC' || row.asset === 'ETH'
|
| 93 |
+
const seq = isCrypto ? rawSeq : (await filterRowsToNyseTradingDays(rawSeq) || [])
|
| 94 |
+
if (!seq.length) { try{ this.chart && this.chart.destroy() }catch(_){}; return }
|
| 95 |
+
|
| 96 |
+
const strategyCfg = (STRATEGIES || []).find(s => s.id === row.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 }
|
| 97 |
+
const st = computeStrategyEquity(seq, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode)
|
| 98 |
+
const bh = computeBuyHoldEquity(seq, 100000)
|
| 99 |
+
const labels = seq.map(s => s.date)
|
| 100 |
+
// keep arrays aligned in length
|
| 101 |
+
const n = Math.min(st.length, bh.length, labels.length)
|
| 102 |
+
const stS = st.slice(0, n)
|
| 103 |
+
const bhS = bh.slice(0, n)
|
| 104 |
+
const lab = labels.slice(0, n)
|
| 105 |
+
|
| 106 |
+
const stRet = stS.length ? ((stS[stS.length-1] - stS[0]) / stS[0]) * 100 : 0
|
| 107 |
+
const bhRet = bhS.length ? ((bhS[bhS.length-1] - bhS[0]) / bhS[0]) * 100 : 0
|
| 108 |
+
const vsBh = stRet - bhRet
|
| 109 |
+
const agentColor = vsBh >= 0 ? '#16a34a' : '#dc2626' // green when outperform, red when underperform
|
| 110 |
+
const agentBgColor = vsBh >= 0 ? 'rgba(22,163,74,0.15)' : 'rgba(220,38,38,0.15)'
|
| 111 |
+
const baselineColor = '#3b82f6' // blue
|
| 112 |
+
|
| 113 |
+
try{ this.chart && this.chart.destroy() }catch(_){ }
|
| 114 |
+
this.chart = new Chart(this.$refs.perfCanvas.getContext('2d'), {
|
| 115 |
+
type: 'line',
|
| 116 |
+
data: {
|
| 117 |
+
labels: lab,
|
| 118 |
+
datasets: [
|
| 119 |
+
{
|
| 120 |
+
label: 'Agent Balance',
|
| 121 |
+
data: stS,
|
| 122 |
+
borderColor: agentColor,
|
| 123 |
+
backgroundColor: agentBgColor,
|
| 124 |
+
pointRadius: 3,
|
| 125 |
+
tension: 0.2
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
label: 'Baseline',
|
| 129 |
+
data: bhS,
|
| 130 |
+
borderColor: baselineColor,
|
| 131 |
+
borderDash: [6,4],
|
| 132 |
+
pointRadius: 0,
|
| 133 |
+
tension: 0.2
|
| 134 |
+
}
|
| 135 |
+
]
|
| 136 |
+
},
|
| 137 |
+
options: {
|
| 138 |
+
responsive: true,
|
| 139 |
+
maintainAspectRatio: false,
|
| 140 |
+
animation: false,
|
| 141 |
+
interaction: { mode: 'index', intersect: false },
|
| 142 |
+
plugins: {
|
| 143 |
+
legend: { display: true },
|
| 144 |
+
tooltip: {
|
| 145 |
+
callbacks: {
|
| 146 |
+
title: (items) => items && items.length ? `Date: ${items[0].label}` : '',
|
| 147 |
+
afterTitle: () => '',
|
| 148 |
+
label: (ctx) => `${ctx.dataset.label}: ${this.fmtMoney(ctx.parsed.y)}`,
|
| 149 |
+
afterBody: (items) => {
|
| 150 |
+
const idx = items && items.length ? items[0].dataIndex : null
|
| 151 |
+
let sRet = stRet
|
| 152 |
+
let bRet = bhRet
|
| 153 |
+
if (idx != null && idx >= 0) {
|
| 154 |
+
if (stS.length > 0 && stS[idx] != null) sRet = ((stS[idx] - stS[0]) / stS[0]) * 100
|
| 155 |
+
if (bhS.length > 0 && bhS[idx] != null) bRet = ((bhS[idx] - bhS[0]) / bhS[0]) * 100
|
| 156 |
+
}
|
| 157 |
+
return [
|
| 158 |
+
`Strategy Return: ${sRet.toFixed(2)}%`,
|
| 159 |
+
`Baseline Return: ${bRet.toFixed(2)}%`
|
| 160 |
+
]
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
vLinePlugin: { color: 'rgba(0,0,0,0.35)', lineWidth: 1, dash: [4,4] },
|
| 165 |
+
zoom: {
|
| 166 |
+
zoom: {
|
| 167 |
+
drag: {
|
| 168 |
+
enabled: true,
|
| 169 |
+
backgroundColor: 'rgba(59, 130, 246, 0.2)',
|
| 170 |
+
borderColor: 'rgba(59, 130, 246, 0.8)',
|
| 171 |
+
borderWidth: 1
|
| 172 |
+
},
|
| 173 |
+
mode: 'x',
|
| 174 |
+
onZoomComplete: () => {
|
| 175 |
+
this.isZoomed = true
|
| 176 |
+
}
|
| 177 |
+
},
|
| 178 |
+
pan: {
|
| 179 |
+
enabled: true,
|
| 180 |
+
mode: 'x',
|
| 181 |
+
modifierKey: 'shift'
|
| 182 |
+
},
|
| 183 |
+
limits: {
|
| 184 |
+
x: { min: 'original', max: 'original' }
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
scales: {
|
| 189 |
+
x: { ticks: { autoSkip: true, maxTicksLimit: 8 } },
|
| 190 |
+
y: { beginAtZero: false }
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
})
|
| 194 |
+
},
|
| 195 |
+
resetZoom(){
|
| 196 |
+
if (this.chart) {
|
| 197 |
+
this.chart.resetZoom()
|
| 198 |
+
this.isZoomed = false
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
</script>
|
src/lib/chartColors.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 图表颜色管理 - 为 Agent 自动分配和管理色系
|
| 3 |
+
* 核心规则:
|
| 4 |
+
* - 所有 baseline 使用黑色
|
| 5 |
+
* - 同一 agent 使用相同色调,通过亮度变化区分不同策略
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
// 调色板 - 高饱和度、鲜艳的颜色,优先使用对比度强的颜色
|
| 9 |
+
const COLOR_PALETTE = [
|
| 10 |
+
'#ef4444', // 鲜红
|
| 11 |
+
'#3b82f6', // 亮蓝
|
| 12 |
+
'#22c55e', // 股票绿(涨绿)
|
| 13 |
+
'#a855f7', // 亮紫
|
| 14 |
+
'#f59e0b', // 琥珀橙
|
| 15 |
+
'#ec4899', // 粉红
|
| 16 |
+
'#06b6d4', // 青色
|
| 17 |
+
'#d946ef', // 洋红
|
| 18 |
+
'#84cc16', // 柠檬绿
|
| 19 |
+
'#8b5cf6', // 靛紫
|
| 20 |
+
'#f97316', // 橙色
|
| 21 |
+
'#10b981', // 翠绿
|
| 22 |
+
'#14b8a6', // 蓝绿
|
| 23 |
+
'#eab308', // 黄色
|
| 24 |
+
'#6366f1', // 蓝紫
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
// Baseline 颜色(统一黑色)
|
| 28 |
+
const BASELINE_COLOR = '#1f2937'
|
| 29 |
+
|
| 30 |
+
// Agent 颜色缓存:agent -> base color
|
| 31 |
+
const agentColorMap = new Map()
|
| 32 |
+
let colorIndex = 0
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* 将 hex 颜色转换为 RGB
|
| 36 |
+
*/
|
| 37 |
+
function hexToRgb(hex) {
|
| 38 |
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
| 39 |
+
return result ? {
|
| 40 |
+
r: parseInt(result[1], 16),
|
| 41 |
+
g: parseInt(result[2], 16),
|
| 42 |
+
b: parseInt(result[3], 16)
|
| 43 |
+
} : { r: 0, g: 0, b: 0 }
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* 将 RGB 转换为 HSL
|
| 48 |
+
*/
|
| 49 |
+
function rgbToHsl(r, g, b) {
|
| 50 |
+
r /= 255
|
| 51 |
+
g /= 255
|
| 52 |
+
b /= 255
|
| 53 |
+
const max = Math.max(r, g, b)
|
| 54 |
+
const min = Math.min(r, g, b)
|
| 55 |
+
let h, s, l = (max + min) / 2
|
| 56 |
+
|
| 57 |
+
if (max === min) {
|
| 58 |
+
h = s = 0
|
| 59 |
+
} else {
|
| 60 |
+
const d = max - min
|
| 61 |
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
| 62 |
+
switch (max) {
|
| 63 |
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
|
| 64 |
+
case g: h = ((b - r) / d + 2) / 6; break
|
| 65 |
+
case b: h = ((r - g) / d + 4) / 6; break
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return { h: h * 360, s: s * 100, l: l * 100 }
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* 将 HSL 转换为 hex
|
| 74 |
+
*/
|
| 75 |
+
function hslToHex(h, s, l) {
|
| 76 |
+
h /= 360
|
| 77 |
+
s /= 100
|
| 78 |
+
l /= 100
|
| 79 |
+
let r, g, b
|
| 80 |
+
|
| 81 |
+
if (s === 0) {
|
| 82 |
+
r = g = b = l
|
| 83 |
+
} else {
|
| 84 |
+
const hue2rgb = (p, q, t) => {
|
| 85 |
+
if (t < 0) t += 1
|
| 86 |
+
if (t > 1) t -= 1
|
| 87 |
+
if (t < 1/6) return p + (q - p) * 6 * t
|
| 88 |
+
if (t < 1/2) return q
|
| 89 |
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
| 90 |
+
return p
|
| 91 |
+
}
|
| 92 |
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
| 93 |
+
const p = 2 * l - q
|
| 94 |
+
r = hue2rgb(p, q, h + 1/3)
|
| 95 |
+
g = hue2rgb(p, q, h)
|
| 96 |
+
b = hue2rgb(p, q, h - 1/3)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const toHex = x => {
|
| 100 |
+
const hex = Math.round(x * 255).toString(16)
|
| 101 |
+
return hex.length === 1 ? '0' + hex : hex
|
| 102 |
+
}
|
| 103 |
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* 调整颜色亮度
|
| 108 |
+
* @param {string} hexColor - hex 颜色
|
| 109 |
+
* @param {number} lightnessAdjust - 亮度调整(-100 到 100)
|
| 110 |
+
*/
|
| 111 |
+
function adjustLightness(hexColor, lightnessAdjust) {
|
| 112 |
+
const rgb = hexToRgb(hexColor)
|
| 113 |
+
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b)
|
| 114 |
+
const newL = Math.max(0, Math.min(100, hsl.l + lightnessAdjust))
|
| 115 |
+
return hslToHex(hsl.h, hsl.s, newL)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* 获取 Agent 的基础颜色(带缓存)
|
| 120 |
+
* @param {string} agent - Agent 名称
|
| 121 |
+
* @returns {string} hex 颜色
|
| 122 |
+
*/
|
| 123 |
+
export function getAgentBaseColor(agent) {
|
| 124 |
+
if (!agentColorMap.has(agent)) {
|
| 125 |
+
const color = COLOR_PALETTE[colorIndex % COLOR_PALETTE.length]
|
| 126 |
+
agentColorMap.set(agent, color)
|
| 127 |
+
colorIndex++
|
| 128 |
+
}
|
| 129 |
+
return agentColorMap.get(agent)
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* 为策略线生成颜色
|
| 134 |
+
* @param {string} agent - Agent 名称
|
| 135 |
+
* @param {boolean} isBaseline - 是否为基线
|
| 136 |
+
* @param {number} strategyIndex - 同一 agent 内的策略索引(0, 1, 2...)用于区分同一 agent 的多条线
|
| 137 |
+
* @returns {string} hex 颜色
|
| 138 |
+
*/
|
| 139 |
+
export function getStrategyColor(agent, isBaseline = false, strategyIndex = 0) {
|
| 140 |
+
if (isBaseline) {
|
| 141 |
+
// 所有 baseline 统一使用黑色
|
| 142 |
+
return BASELINE_COLOR
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const baseColor = getAgentBaseColor(agent)
|
| 146 |
+
|
| 147 |
+
// 第一条策略线使用基础颜色
|
| 148 |
+
if (strategyIndex === 0) {
|
| 149 |
+
return baseColor
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// 后续策略线通过调整亮度来区分(同一个 agent 的不同资产/策略)
|
| 153 |
+
// 奇数索引变暗,偶数索引变亮,但限制调整范围避免太暗或太亮
|
| 154 |
+
const step = Math.ceil(strategyIndex / 2)
|
| 155 |
+
const adjustment = strategyIndex % 2 === 1
|
| 156 |
+
? -Math.min(8 * step, 20) // 最多变暗 20%
|
| 157 |
+
: Math.min(8 * step, 25) // 最多变亮 25%
|
| 158 |
+
|
| 159 |
+
return adjustLightness(baseColor, adjustment)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* 根据 model 获取线型(borderDash 配置)
|
| 164 |
+
* @param {string} model - Model 名称
|
| 165 |
+
* @returns {Array|undefined} Chart.js borderDash 数组
|
| 166 |
+
*/
|
| 167 |
+
export function getLineStyleForModel(model) {
|
| 168 |
+
if (!model) return undefined // 实线
|
| 169 |
+
|
| 170 |
+
const modelLower = model.toLowerCase()
|
| 171 |
+
|
| 172 |
+
// 为不同的 model 分配不同的线型
|
| 173 |
+
const patterns = {
|
| 174 |
+
'gpt-4': undefined, // 实线
|
| 175 |
+
'gpt-3.5': [5, 5], // 虚线
|
| 176 |
+
'claude': [1, 3], // 点线
|
| 177 |
+
'gemini': [10, 5, 2, 5], // 虚线加点
|
| 178 |
+
'llama': [2, 2], // 密集点线
|
| 179 |
+
'deepseek': [8, 3, 2, 3], // 长虚线加点
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// 精确匹配或包含匹配
|
| 183 |
+
for (const [key, pattern] of Object.entries(patterns)) {
|
| 184 |
+
if (modelLower === key || modelLower.includes(key)) {
|
| 185 |
+
return pattern
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// 未知 model,根据哈希分配线型
|
| 190 |
+
const fallbackPatterns = [
|
| 191 |
+
undefined, // 实线
|
| 192 |
+
[5, 5], // 虚线
|
| 193 |
+
[1, 3], // 点线
|
| 194 |
+
[10, 5, 2, 5], // 虚线加点
|
| 195 |
+
[2, 2], // 密集点线
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
let hash = 0
|
| 199 |
+
for (let i = 0; i < model.length; i++) {
|
| 200 |
+
hash = ((hash << 5) - hash) + model.charCodeAt(i)
|
| 201 |
+
hash = hash & hash
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
return fallbackPatterns[Math.abs(hash) % fallbackPatterns.length]
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* 重置颜色缓存(用于测试或需要重新分配时)
|
| 209 |
+
*/
|
| 210 |
+
export function resetAgentColors() {
|
| 211 |
+
agentColorMap.clear()
|
| 212 |
+
colorIndex = 0
|
| 213 |
+
}
|
| 214 |
+
|
src/lib/dataCache.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let allDecisions = null
|
| 2 |
+
|
| 3 |
+
export function getAllDecisions() {
|
| 4 |
+
return allDecisions
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function setAllDecisions(rows) {
|
| 8 |
+
allDecisions = Array.isArray(rows) ? rows : null
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function clearAllDecisions() {
|
| 12 |
+
allDecisions = null
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
src/lib/dataService.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { supabase } from './supabase.js'
|
| 2 |
+
import { getAllDecisions, setAllDecisions, clearAllDecisions } from './dataCache.js'
|
| 3 |
+
import { writeRawDecisions, clearAllStores, readAllRawDecisions } from './idb.js'
|
| 4 |
+
import { filterRowsToNyseTradingDays, countNonTradingDaysBetweenForAsset, countTradingDaysBetweenForAsset } from './marketCalendar.js'
|
| 5 |
+
import { computeBuyHoldEquity, computeStrategyEquity, calculateMetricsFromSeries, computeWinRate } from './perf.js'
|
| 6 |
+
import { STRATEGIES } from './strategies.js'
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* 全局数据服务
|
| 10 |
+
* 负责从 Supabase 加载数据、缓存管理和数据计算
|
| 11 |
+
*/
|
| 12 |
+
class DataService {
|
| 13 |
+
constructor() {
|
| 14 |
+
this.loading = false
|
| 15 |
+
this.loaded = false
|
| 16 |
+
this.agents = []
|
| 17 |
+
this.tableRows = []
|
| 18 |
+
this.lastUpdated = null
|
| 19 |
+
this.dateBounds = { min: null, max: null }
|
| 20 |
+
this.listeners = new Set()
|
| 21 |
+
|
| 22 |
+
// Filter options cache
|
| 23 |
+
this.useDbFilterCache = this._getDefaultUseDbFilterCache()
|
| 24 |
+
this.nameOptions = []
|
| 25 |
+
this.assetOptions = []
|
| 26 |
+
this.modelOptions = []
|
| 27 |
+
this.strategyOptions = []
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
_getDefaultUseDbFilterCache() {
|
| 31 |
+
try {
|
| 32 |
+
if (localStorage.getItem('disableDbFilterCache') === '1') return false
|
| 33 |
+
} catch (_) {}
|
| 34 |
+
const envFlag = ((import.meta.env.VITE_USE_DB_FILTER_CACHE || '') + '').toLowerCase()
|
| 35 |
+
return envFlag === '1' || envFlag === 'true' || envFlag === 'yes' || envFlag === 'on'
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* 订阅数据变化
|
| 40 |
+
*/
|
| 41 |
+
subscribe(callback) {
|
| 42 |
+
this.listeners.add(callback)
|
| 43 |
+
return () => this.listeners.delete(callback)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* 通知所有订阅者
|
| 48 |
+
*/
|
| 49 |
+
_notify() {
|
| 50 |
+
this.listeners.forEach(callback => {
|
| 51 |
+
try {
|
| 52 |
+
callback({
|
| 53 |
+
loading: this.loading,
|
| 54 |
+
loaded: this.loaded,
|
| 55 |
+
agents: this.agents,
|
| 56 |
+
tableRows: this.tableRows,
|
| 57 |
+
lastUpdated: this.lastUpdated,
|
| 58 |
+
dateBounds: this.dateBounds,
|
| 59 |
+
nameOptions: this.nameOptions,
|
| 60 |
+
assetOptions: this.assetOptions,
|
| 61 |
+
modelOptions: this.modelOptions,
|
| 62 |
+
strategyOptions: this.strategyOptions
|
| 63 |
+
})
|
| 64 |
+
} catch (e) {
|
| 65 |
+
console.error('[DataService] Error notifying listener:', e)
|
| 66 |
+
}
|
| 67 |
+
})
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* 从数据库加载筛选选项
|
| 72 |
+
*/
|
| 73 |
+
async loadFilterOptionsFromDb() {
|
| 74 |
+
if (!this.useDbFilterCache) return null
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const { data, error } = await supabase
|
| 78 |
+
.from('filter_options')
|
| 79 |
+
.select('names, assets, models, strategies, updated_at')
|
| 80 |
+
.eq('id', 'latest')
|
| 81 |
+
.maybeSingle()
|
| 82 |
+
|
| 83 |
+
if (error || !data) {
|
| 84 |
+
const msg = (error && error.message) ? String(error.message).toLowerCase() : ''
|
| 85 |
+
const code = (error && error.code) ? String(error.code) : ''
|
| 86 |
+
const isNotFound = msg.includes('not found') || msg.includes('does not exist') || code === '42P01' || code === 'PGRST116'
|
| 87 |
+
|
| 88 |
+
if (isNotFound) {
|
| 89 |
+
this.useDbFilterCache = false
|
| 90 |
+
try { localStorage.setItem('disableDbFilterCache', '1') } catch (_) {}
|
| 91 |
+
}
|
| 92 |
+
return null
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
names: data.names || [],
|
| 97 |
+
assets: data.assets || [],
|
| 98 |
+
models: data.models || [],
|
| 99 |
+
strategies: data.strategies || []
|
| 100 |
+
}
|
| 101 |
+
} catch (e) {
|
| 102 |
+
const msg = e && e.message ? String(e.message).toLowerCase() : ''
|
| 103 |
+
if (msg.includes('not found') || msg.includes('does not exist')) {
|
| 104 |
+
this.useDbFilterCache = false
|
| 105 |
+
try { localStorage.setItem('disableDbFilterCache', '1') } catch (_) {}
|
| 106 |
+
}
|
| 107 |
+
return null
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* 保存筛选选项到数据库
|
| 113 |
+
*/
|
| 114 |
+
async saveFilterOptionsToDb(names, assets, models, strategies) {
|
| 115 |
+
if (!this.useDbFilterCache) return
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
await supabase
|
| 119 |
+
.from('filter_options')
|
| 120 |
+
.upsert({
|
| 121 |
+
id: 'latest',
|
| 122 |
+
names,
|
| 123 |
+
assets,
|
| 124 |
+
models,
|
| 125 |
+
strategies,
|
| 126 |
+
updated_at: new Date().toISOString()
|
| 127 |
+
}, { onConflict: 'id' })
|
| 128 |
+
} catch (e) {
|
| 129 |
+
const msg = e && e.message ? String(e.message).toLowerCase() : ''
|
| 130 |
+
if (msg.includes('not found') || msg.includes('does not exist')) {
|
| 131 |
+
this.useDbFilterCache = false
|
| 132 |
+
try { localStorage.setItem('disableDbFilterCache', '1') } catch (_) {}
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* 从 Supabase 拉取所有数据
|
| 139 |
+
*/
|
| 140 |
+
async _fetchAllFromRemote() {
|
| 141 |
+
const pageSize = 1000
|
| 142 |
+
let from = 0
|
| 143 |
+
const all = []
|
| 144 |
+
|
| 145 |
+
while (true) {
|
| 146 |
+
const to = from + pageSize - 1
|
| 147 |
+
const { data, error } = await supabase
|
| 148 |
+
.from('trading_decisions')
|
| 149 |
+
.select('id, agent_name, asset, model, date, price, recommended_action, news_count, sentiment, created_at, updated_at')
|
| 150 |
+
.order('updated_at', { ascending: false })
|
| 151 |
+
.range(from, to)
|
| 152 |
+
|
| 153 |
+
if (error) {
|
| 154 |
+
console.error('[DataService] Error fetching data:', error)
|
| 155 |
+
break
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
all.push(...(data || []))
|
| 159 |
+
|
| 160 |
+
if (!data || data.length < pageSize) break
|
| 161 |
+
from += pageSize
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
return all
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* 计算单个分组的指标
|
| 169 |
+
*/
|
| 170 |
+
async _computeAgentMetrics(row, allDecisions) {
|
| 171 |
+
try {
|
| 172 |
+
// 构建完整时间序列
|
| 173 |
+
const seq = allDecisions
|
| 174 |
+
.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model)
|
| 175 |
+
.sort((a, b) => (a.date > b.date ? 1 : -1))
|
| 176 |
+
|
| 177 |
+
const isCrypto = row.asset === 'BTC' || row.asset === 'ETH'
|
| 178 |
+
const seqFiltered = isCrypto ? seq : (await filterRowsToNyseTradingDays(seq))
|
| 179 |
+
|
| 180 |
+
// 计算日期范围
|
| 181 |
+
const dates = seqFiltered.map(s => s.date).filter(Boolean).sort()
|
| 182 |
+
const start_date = dates[0] || '-'
|
| 183 |
+
const end_date = dates[dates.length - 1] || '-'
|
| 184 |
+
|
| 185 |
+
let closed_days = 0
|
| 186 |
+
if (dates.length > 1) {
|
| 187 |
+
closed_days = await countNonTradingDaysBetweenForAsset(row.asset, start_date, end_date)
|
| 188 |
+
}
|
| 189 |
+
const trading_days = await countTradingDaysBetweenForAsset(row.asset, start_date, end_date)
|
| 190 |
+
|
| 191 |
+
// 为每个策略计算指标
|
| 192 |
+
const results = []
|
| 193 |
+
for (const s of STRATEGIES) {
|
| 194 |
+
const st = computeStrategyEquity(seqFiltered, 100000, s.fee, s.strategy, s.tradingMode)
|
| 195 |
+
const stNoFee = computeStrategyEquity(seqFiltered, 100000, 0, s.strategy, s.tradingMode)
|
| 196 |
+
const metrics = calculateMetricsFromSeries(st, isCrypto ? 'crypto' : 'stock')
|
| 197 |
+
const metricsNoFee = calculateMetricsFromSeries(stNoFee, isCrypto ? 'crypto' : 'stock')
|
| 198 |
+
const { winRate, trades } = computeWinRate(seqFiltered, s.strategy, s.tradingMode)
|
| 199 |
+
const bhSeries = computeBuyHoldEquity(seqFiltered, 100000)
|
| 200 |
+
const buy_hold_return = bhSeries.length ? (bhSeries[bhSeries.length - 1] - bhSeries[0]) / bhSeries[0] : 0
|
| 201 |
+
|
| 202 |
+
results.push({
|
| 203 |
+
decision_ids: seqFiltered.map(r => r.id).filter(Boolean),
|
| 204 |
+
agent_name: row.agent_name,
|
| 205 |
+
asset: row.asset,
|
| 206 |
+
model: row.model,
|
| 207 |
+
strategy: s.id,
|
| 208 |
+
strategy_label: s.label,
|
| 209 |
+
balance: st.length ? st[st.length - 1] : 100000,
|
| 210 |
+
return_with_fees: metrics.total_return / 100,
|
| 211 |
+
return_no_fees: metricsNoFee.total_return / 100,
|
| 212 |
+
buy_hold_return,
|
| 213 |
+
sharpe_ratio: metrics.sharpe_ratio,
|
| 214 |
+
win_rate: winRate,
|
| 215 |
+
trades,
|
| 216 |
+
start_date,
|
| 217 |
+
end_date,
|
| 218 |
+
closed_days,
|
| 219 |
+
trading_days,
|
| 220 |
+
fee: s.fee,
|
| 221 |
+
tradingMode: s.tradingMode,
|
| 222 |
+
series: seqFiltered.map(r => ({
|
| 223 |
+
id: r.id,
|
| 224 |
+
date: r.date,
|
| 225 |
+
price: r.price,
|
| 226 |
+
recommended_action: r.recommended_action
|
| 227 |
+
})),
|
| 228 |
+
key: `${row.agent_name}|${row.asset}|${row.model}|${s.id}`
|
| 229 |
+
})
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
return results
|
| 233 |
+
} catch (e) {
|
| 234 |
+
console.error('[DataService] Error computing metrics:', e)
|
| 235 |
+
return []
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/**
|
| 240 |
+
* 加载数据(主方法)
|
| 241 |
+
*/
|
| 242 |
+
async load(forceRefresh = false) {
|
| 243 |
+
if (this.loading) {
|
| 244 |
+
console.log('[DataService] Already loading, skipping...')
|
| 245 |
+
return
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
this.loading = true
|
| 249 |
+
this._notify()
|
| 250 |
+
|
| 251 |
+
try {
|
| 252 |
+
// 1. 尝试从数据库加载筛选选项
|
| 253 |
+
await this.loadFilterOptionsFromDb()
|
| 254 |
+
|
| 255 |
+
// 2. 获取所有决策数据
|
| 256 |
+
let all = null
|
| 257 |
+
|
| 258 |
+
if (forceRefresh) {
|
| 259 |
+
// 强制刷新:清除所有缓存,从远程拉取
|
| 260 |
+
await clearAllStores()
|
| 261 |
+
clearAllDecisions()
|
| 262 |
+
all = await this._fetchAllFromRemote()
|
| 263 |
+
setAllDecisions(all)
|
| 264 |
+
await writeRawDecisions(all)
|
| 265 |
+
} else {
|
| 266 |
+
// 正常加载:先尝试内存缓存
|
| 267 |
+
all = getAllDecisions()
|
| 268 |
+
|
| 269 |
+
if (!all) {
|
| 270 |
+
// 再尝试 IndexedDB 缓存
|
| 271 |
+
const cached = await readAllRawDecisions()
|
| 272 |
+
if (cached && cached.length) {
|
| 273 |
+
all = cached
|
| 274 |
+
setAllDecisions(all)
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
if (!all) {
|
| 279 |
+
// 最后从远程拉取
|
| 280 |
+
all = await this._fetchAllFromRemote()
|
| 281 |
+
setAllDecisions(all)
|
| 282 |
+
await writeRawDecisions(all)
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// 3. 计算全局日期范围
|
| 287 |
+
const allDates = all.map(r => r && r.date).filter(Boolean).sort()
|
| 288 |
+
this.dateBounds = {
|
| 289 |
+
min: allDates.length ? new Date(allDates[0]) : null,
|
| 290 |
+
max: allDates.length ? new Date(allDates[allDates.length - 1]) : null
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// 4. 按 agent_name|asset|model 分组去重
|
| 294 |
+
const keyToRow = new Map()
|
| 295 |
+
for (const row of all) {
|
| 296 |
+
const key = `${row.agent_name}|${row.asset}|${row.model}`
|
| 297 |
+
if (!keyToRow.has(key)) {
|
| 298 |
+
keyToRow.set(key, row)
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// 5. 计算每个分组的指标
|
| 303 |
+
const agents = []
|
| 304 |
+
for (const row of keyToRow.values()) {
|
| 305 |
+
const metrics = await this._computeAgentMetrics(row, all)
|
| 306 |
+
agents.push(...metrics)
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
this.agents = agents
|
| 310 |
+
|
| 311 |
+
// 6. 转换为表格行
|
| 312 |
+
this.tableRows = agents.map(a => ({
|
| 313 |
+
agent_name: a.agent_name,
|
| 314 |
+
asset: a.asset,
|
| 315 |
+
model: a.model,
|
| 316 |
+
strategy: a.strategy,
|
| 317 |
+
strategy_label: a.strategy_label,
|
| 318 |
+
balance: a.balance,
|
| 319 |
+
ret_with_fees: a.return_with_fees,
|
| 320 |
+
ret_no_fees: a.return_no_fees,
|
| 321 |
+
buy_hold: a.buy_hold_return,
|
| 322 |
+
vs_bh_with_fees: a.return_with_fees - a.buy_hold_return,
|
| 323 |
+
sharpe: a.sharpe_ratio,
|
| 324 |
+
trading_days: a.trading_days,
|
| 325 |
+
win_rate: a.win_rate,
|
| 326 |
+
trades: a.trades,
|
| 327 |
+
start_date: a.start_date,
|
| 328 |
+
end_date: a.end_date,
|
| 329 |
+
closed_date: a.closed_days,
|
| 330 |
+
decision_ids: a.decision_ids,
|
| 331 |
+
fee: a.fee,
|
| 332 |
+
tradingMode: a.tradingMode,
|
| 333 |
+
series: a.series,
|
| 334 |
+
key: a.key
|
| 335 |
+
}))
|
| 336 |
+
|
| 337 |
+
// 7. 生成筛选选项
|
| 338 |
+
this.nameOptions = Array.from(new Set(agents.map(a => a.agent_name)))
|
| 339 |
+
.map(v => ({ label: v, value: v }))
|
| 340 |
+
this.assetOptions = Array.from(new Set(agents.map(a => a.asset)))
|
| 341 |
+
.map(v => ({ label: v, value: v }))
|
| 342 |
+
this.modelOptions = Array.from(new Set(agents.map(a => a.model)))
|
| 343 |
+
.map(v => ({ label: v, value: v }))
|
| 344 |
+
this.strategyOptions = STRATEGIES.map(s => ({ label: s.label, value: s.id }))
|
| 345 |
+
|
| 346 |
+
// 8. 保存筛选选项到数据库
|
| 347 |
+
await this.saveFilterOptionsToDb(
|
| 348 |
+
this.nameOptions.map(o => o.value),
|
| 349 |
+
this.assetOptions.map(o => o.value),
|
| 350 |
+
this.modelOptions.map(o => o.value),
|
| 351 |
+
this.strategyOptions.map(o => o.value)
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
this.lastUpdated = Date.now()
|
| 355 |
+
this.loaded = true
|
| 356 |
+
|
| 357 |
+
console.log('[DataService] Data loaded successfully:', {
|
| 358 |
+
agents: this.agents.length,
|
| 359 |
+
tableRows: this.tableRows.length,
|
| 360 |
+
names: this.nameOptions.length,
|
| 361 |
+
assets: this.assetOptions.length
|
| 362 |
+
})
|
| 363 |
+
} catch (e) {
|
| 364 |
+
console.error('[DataService] Error loading data:', e)
|
| 365 |
+
} finally {
|
| 366 |
+
this.loading = false
|
| 367 |
+
this._notify()
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
/**
|
| 372 |
+
* 强制刷新数据
|
| 373 |
+
*/
|
| 374 |
+
async forceRefresh() {
|
| 375 |
+
return this.load(true)
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
/**
|
| 379 |
+
* 获取当前状态
|
| 380 |
+
*/
|
| 381 |
+
getState() {
|
| 382 |
+
return {
|
| 383 |
+
loading: this.loading,
|
| 384 |
+
loaded: this.loaded,
|
| 385 |
+
agents: this.agents,
|
| 386 |
+
tableRows: this.tableRows,
|
| 387 |
+
lastUpdated: this.lastUpdated,
|
| 388 |
+
dateBounds: this.dateBounds,
|
| 389 |
+
nameOptions: this.nameOptions,
|
| 390 |
+
assetOptions: this.assetOptions,
|
| 391 |
+
modelOptions: this.modelOptions,
|
| 392 |
+
strategyOptions: this.strategyOptions
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// 导出单例
|
| 398 |
+
export const dataService = new DataService()
|
| 399 |
+
|
src/lib/idb.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function openDb() {
|
| 2 |
+
return new Promise((resolve, reject) => {
|
| 3 |
+
const req = indexedDB.open('paper_trading_viz', 2)
|
| 4 |
+
req.onupgradeneeded = () => {
|
| 5 |
+
const db = req.result
|
| 6 |
+
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' })
|
| 7 |
+
if (!db.objectStoreNames.contains('raw_decisions')) {
|
| 8 |
+
const raw = db.createObjectStore('raw_decisions', { keyPath: 'id' })
|
| 9 |
+
if (!raw.indexNames.contains('group')) raw.createIndex('group', 'group', { unique: false })
|
| 10 |
+
if (!raw.indexNames.contains('updated_at')) raw.createIndex('updated_at', 'updated_at', { unique: false })
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
req.onsuccess = () => resolve(req.result)
|
| 14 |
+
req.onerror = () => reject(req.error)
|
| 15 |
+
})
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// removed aggregates helpers
|
| 19 |
+
|
| 20 |
+
export async function readSyncMeta() {
|
| 21 |
+
const db = await openDb()
|
| 22 |
+
return new Promise((resolve, reject) => {
|
| 23 |
+
const tx = db.transaction('meta', 'readonly')
|
| 24 |
+
const store = tx.objectStore('meta')
|
| 25 |
+
const req = store.get('raw_sync')
|
| 26 |
+
req.onsuccess = () => resolve(req.result || null)
|
| 27 |
+
req.onerror = () => reject(req.error)
|
| 28 |
+
})
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export async function writeSyncMeta(remoteUpdatedAt, lastSyncedAt) {
|
| 32 |
+
const db = await openDb()
|
| 33 |
+
return new Promise((resolve, reject) => {
|
| 34 |
+
const tx = db.transaction('meta', 'readwrite')
|
| 35 |
+
const store = tx.objectStore('meta')
|
| 36 |
+
store.put({ id: 'raw_sync', remoteUpdatedAt: remoteUpdatedAt || null, lastSyncedAt: lastSyncedAt || new Date().toISOString() })
|
| 37 |
+
tx.oncomplete = () => resolve(true)
|
| 38 |
+
tx.onerror = () => reject(tx.error)
|
| 39 |
+
})
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export async function writeRawDecisions(rows) {
|
| 43 |
+
const db = await openDb()
|
| 44 |
+
return new Promise((resolve, reject) => {
|
| 45 |
+
const tx = db.transaction('raw_decisions', 'readwrite')
|
| 46 |
+
const store = tx.objectStore('raw_decisions')
|
| 47 |
+
const clearReq = store.clear()
|
| 48 |
+
clearReq.onsuccess = () => {
|
| 49 |
+
for (const r of rows) {
|
| 50 |
+
const key = r.id
|
| 51 |
+
const group = `${r.agent_name}|${r.asset}|${r.model}`
|
| 52 |
+
store.put({ ...r, id: key, group })
|
| 53 |
+
}
|
| 54 |
+
tx.oncomplete = () => resolve(true)
|
| 55 |
+
tx.onerror = () => reject(tx.error)
|
| 56 |
+
}
|
| 57 |
+
clearReq.onerror = () => reject(clearReq.error)
|
| 58 |
+
})
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export async function upsertRawDecisions(rows) {
|
| 62 |
+
const db = await openDb()
|
| 63 |
+
return new Promise((resolve, reject) => {
|
| 64 |
+
const tx = db.transaction('raw_decisions', 'readwrite')
|
| 65 |
+
const store = tx.objectStore('raw_decisions')
|
| 66 |
+
for (const r of rows) {
|
| 67 |
+
const key = r.id
|
| 68 |
+
const group = `${r.agent_name}|${r.asset}|${r.model}`
|
| 69 |
+
store.put({ ...r, id: key, group })
|
| 70 |
+
}
|
| 71 |
+
tx.oncomplete = () => resolve(true)
|
| 72 |
+
tx.onerror = () => reject(tx.error)
|
| 73 |
+
})
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export async function readRawByGroup(groupKey) {
|
| 77 |
+
const db = await openDb()
|
| 78 |
+
return new Promise((resolve, reject) => {
|
| 79 |
+
const tx = db.transaction('raw_decisions', 'readonly')
|
| 80 |
+
const store = tx.objectStore('raw_decisions')
|
| 81 |
+
const idx = store.index('group')
|
| 82 |
+
const req = idx.openCursor(IDBKeyRange.only(groupKey))
|
| 83 |
+
const rows = []
|
| 84 |
+
req.onsuccess = e => {
|
| 85 |
+
const cursor = e.target.result
|
| 86 |
+
if (cursor) { rows.push(cursor.value); cursor.continue() } else { resolve(rows) }
|
| 87 |
+
}
|
| 88 |
+
req.onerror = () => reject(req.error)
|
| 89 |
+
})
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export async function readAllRawDecisions() {
|
| 93 |
+
const db = await openDb()
|
| 94 |
+
return new Promise((resolve, reject) => {
|
| 95 |
+
const tx = db.transaction('raw_decisions', 'readonly')
|
| 96 |
+
const store = tx.objectStore('raw_decisions')
|
| 97 |
+
const rows = []
|
| 98 |
+
const cursorReq = store.openCursor()
|
| 99 |
+
cursorReq.onsuccess = e => {
|
| 100 |
+
const cursor = e.target.result
|
| 101 |
+
if (cursor) { rows.push(cursor.value); cursor.continue() } else { resolve(rows) }
|
| 102 |
+
}
|
| 103 |
+
cursorReq.onerror = () => reject(cursorReq.error)
|
| 104 |
+
})
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export async function readRawUpdatedAtMax() {
|
| 108 |
+
const db = await openDb()
|
| 109 |
+
return new Promise((resolve, reject) => {
|
| 110 |
+
const tx = db.transaction('raw_decisions', 'readonly')
|
| 111 |
+
const store = tx.objectStore('raw_decisions')
|
| 112 |
+
const index = store.index('updated_at')
|
| 113 |
+
const req = index.openCursor(null, 'prev')
|
| 114 |
+
req.onsuccess = e => {
|
| 115 |
+
const cursor = e.target.result
|
| 116 |
+
resolve(cursor ? cursor.value.updated_at : null)
|
| 117 |
+
}
|
| 118 |
+
req.onerror = () => reject(req.error)
|
| 119 |
+
})
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export async function clearAllStores() {
|
| 123 |
+
const db = await openDb()
|
| 124 |
+
return new Promise((resolve, reject) => {
|
| 125 |
+
const stores = []
|
| 126 |
+
if (db.objectStoreNames.contains('meta')) stores.push('meta')
|
| 127 |
+
if (db.objectStoreNames.contains('raw_decisions')) stores.push('raw_decisions')
|
| 128 |
+
if (stores.length === 0) { resolve(true); return }
|
| 129 |
+
const tx = db.transaction(stores, 'readwrite')
|
| 130 |
+
if (db.objectStoreNames.contains('meta')) tx.objectStore('meta').clear()
|
| 131 |
+
if (db.objectStoreNames.contains('raw_decisions')) tx.objectStore('raw_decisions').clear()
|
| 132 |
+
tx.oncomplete = () => resolve(true)
|
| 133 |
+
tx.onerror = () => reject(tx.error)
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
src/lib/marketCalendar.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Trading calendar utilities for NYSE trading days
|
| 2 |
+
// This implementation relies on the 'nyse-holidays' package for holiday detection.
|
| 3 |
+
|
| 4 |
+
let nyseModule = null
|
| 5 |
+
let nyseLoadAttempted = false
|
| 6 |
+
const holidayCache = new Map() // key: YYYY-MM-DD, value: boolean
|
| 7 |
+
|
| 8 |
+
async function loadNyseModule() {
|
| 9 |
+
if (nyseLoadAttempted) return nyseModule
|
| 10 |
+
nyseLoadAttempted = true
|
| 11 |
+
try {
|
| 12 |
+
// dynamic import so the app still runs even if the package isn't installed
|
| 13 |
+
nyseModule = await import('nyse-holidays')
|
| 14 |
+
} catch (_) {
|
| 15 |
+
nyseModule = null
|
| 16 |
+
}
|
| 17 |
+
return nyseModule
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function toIsoDateString(dateLike) {
|
| 21 |
+
if (typeof dateLike === 'string') return dateLike.slice(0, 10)
|
| 22 |
+
try { return new Date(dateLike).toISOString().slice(0, 10) } catch { return '' }
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function isWeekend(dateLike) {
|
| 26 |
+
try {
|
| 27 |
+
const d = new Date(dateLike)
|
| 28 |
+
const day = d.getUTCDay()
|
| 29 |
+
return day === 0 || day === 6
|
| 30 |
+
} catch {
|
| 31 |
+
return false
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function isNyseHoliday(dateLike) {
|
| 36 |
+
const iso = toIsoDateString(dateLike)
|
| 37 |
+
if (!iso) return false
|
| 38 |
+
if (holidayCache.has(iso)) return holidayCache.get(iso)
|
| 39 |
+
await loadNyseModule()
|
| 40 |
+
let isHoliday = false
|
| 41 |
+
try {
|
| 42 |
+
if (nyseModule) {
|
| 43 |
+
const mod = nyseModule
|
| 44 |
+
const fn = (mod && typeof mod.isHoliday === 'function')
|
| 45 |
+
? mod.isHoliday
|
| 46 |
+
: (mod && mod.default && typeof mod.default.isHoliday === 'function')
|
| 47 |
+
? mod.default.isHoliday
|
| 48 |
+
: null
|
| 49 |
+
if (fn) {
|
| 50 |
+
// Pass mid-day UTC to avoid timezone shifting to previous/next day
|
| 51 |
+
isHoliday = !!fn(new Date(`${iso}T12:00:00Z`))
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
} catch (_) {
|
| 55 |
+
isHoliday = false
|
| 56 |
+
}
|
| 57 |
+
holidayCache.set(iso, isHoliday)
|
| 58 |
+
return isHoliday
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export async function isNyseTradingDay(dateLike) {
|
| 62 |
+
if (isWeekend(dateLike)) return false
|
| 63 |
+
return !(await isNyseHoliday(dateLike))
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function filterRowsToNyseTradingDays(rows) {
|
| 67 |
+
const list = Array.isArray(rows) ? rows : []
|
| 68 |
+
const out = []
|
| 69 |
+
for (const r of list) {
|
| 70 |
+
if (!r || !r.date) continue
|
| 71 |
+
if (await isNyseTradingDay(r.date)) out.push(r)
|
| 72 |
+
}
|
| 73 |
+
return out
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export async function countMissingNyseTradingDaysBetween(startIso, endIso, presentDateSet) {
|
| 77 |
+
if (!startIso || !endIso) return 0
|
| 78 |
+
const present = new Set(Array.from(presentDateSet || []).map(toIsoDateString))
|
| 79 |
+
let missing = 0
|
| 80 |
+
try {
|
| 81 |
+
const start = new Date(startIso)
|
| 82 |
+
const end = new Date(endIso)
|
| 83 |
+
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
| 84 |
+
const iso = d.toISOString().slice(0, 10)
|
| 85 |
+
if (await isNyseTradingDay(iso)) {
|
| 86 |
+
if (!present.has(iso)) missing++
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
} catch {
|
| 90 |
+
return 0
|
| 91 |
+
}
|
| 92 |
+
return missing
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export async function isTradingDayForAsset(asset, dateLike) {
|
| 96 |
+
// Crypto trades 24/7, all calendar days are trading days
|
| 97 |
+
if (asset === 'BTC' || asset === 'ETH') return true
|
| 98 |
+
// Default to NYSE for stocks
|
| 99 |
+
return await isNyseTradingDay(dateLike)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export async function countMissingTradingDaysBetweenForAsset(asset, startIso, endIso, presentDateSet) {
|
| 103 |
+
if (!startIso || !endIso) return 0
|
| 104 |
+
const present = new Set(Array.from(presentDateSet || []).map(toIsoDateString))
|
| 105 |
+
let missing = 0
|
| 106 |
+
try {
|
| 107 |
+
const start = new Date(startIso)
|
| 108 |
+
const end = new Date(endIso)
|
| 109 |
+
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
| 110 |
+
const iso = d.toISOString().slice(0, 10)
|
| 111 |
+
if (await isTradingDayForAsset(asset, iso)) {
|
| 112 |
+
if (!present.has(iso)) missing++
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
} catch {
|
| 116 |
+
return 0
|
| 117 |
+
}
|
| 118 |
+
return missing
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export async function listMissingTradingDaysBetweenForAsset(asset, startIso, endIso, presentDateSet) {
|
| 122 |
+
if (!startIso || !endIso) return []
|
| 123 |
+
const present = new Set(Array.from(presentDateSet || []).map(toIsoDateString))
|
| 124 |
+
const missing = []
|
| 125 |
+
try {
|
| 126 |
+
const start = new Date(startIso)
|
| 127 |
+
const end = new Date(endIso)
|
| 128 |
+
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
| 129 |
+
const iso = d.toISOString().slice(0, 10)
|
| 130 |
+
if (await isTradingDayForAsset(asset, iso)) {
|
| 131 |
+
if (!present.has(iso)) missing.push(iso)
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
} catch {
|
| 135 |
+
return []
|
| 136 |
+
}
|
| 137 |
+
return missing
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
export async function countNonTradingDaysBetweenForAsset(asset, startIso, endIso) {
|
| 141 |
+
if (!startIso || !endIso) return 0
|
| 142 |
+
// Crypto has no closed days by definition here
|
| 143 |
+
if (asset === 'BTC' || asset === 'ETH') return 0
|
| 144 |
+
let closed = 0
|
| 145 |
+
try {
|
| 146 |
+
const start = new Date(startIso)
|
| 147 |
+
const end = new Date(endIso)
|
| 148 |
+
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
| 149 |
+
const iso = d.toISOString().slice(0, 10)
|
| 150 |
+
if (!(await isNyseTradingDay(iso))) closed++
|
| 151 |
+
}
|
| 152 |
+
} catch {
|
| 153 |
+
return 0
|
| 154 |
+
}
|
| 155 |
+
return closed
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export async function listNonTradingDaysBetweenForAsset(asset, startIso, endIso) {
|
| 159 |
+
if (!startIso || !endIso) return []
|
| 160 |
+
if (asset === 'BTC' || asset === 'ETH') return []
|
| 161 |
+
const closed = []
|
| 162 |
+
try {
|
| 163 |
+
const start = new Date(startIso)
|
| 164 |
+
const end = new Date(endIso)
|
| 165 |
+
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
| 166 |
+
const iso = d.toISOString().slice(0, 10)
|
| 167 |
+
if (!(await isNyseTradingDay(iso))) closed.push(iso)
|
| 168 |
+
}
|
| 169 |
+
} catch {
|
| 170 |
+
return []
|
| 171 |
+
}
|
| 172 |
+
return closed
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
export async function countTradingDaysBetweenForAsset(asset, startIso, endIso) {
|
| 176 |
+
if (!startIso || !endIso) return 0
|
| 177 |
+
try {
|
| 178 |
+
const start = new Date(startIso)
|
| 179 |
+
const end = new Date(endIso)
|
| 180 |
+
if (asset === 'BTC' || asset === 'ETH') {
|
| 181 |
+
const days = Math.max(0, Math.floor((end - start) / 86400000) + 1)
|
| 182 |
+
return days
|
| 183 |
+
}
|
| 184 |
+
let count = 0
|
| 185 |
+
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
| 186 |
+
const iso = d.toISOString().slice(0, 10)
|
| 187 |
+
if (await isNyseTradingDay(iso)) count++
|
| 188 |
+
}
|
| 189 |
+
return count
|
| 190 |
+
} catch {
|
| 191 |
+
return 0
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
src/lib/perf.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// JS port of get_return.py's core logic. Prices are taken from provided rows (date, price, recommended_action).
|
| 2 |
+
|
| 3 |
+
function sanitizeRows(rows) {
|
| 4 |
+
const list = (rows || []).filter(r => r && r.date && r.price != null)
|
| 5 |
+
list.sort((a, b) => (a.date > b.date ? 1 : -1))
|
| 6 |
+
return list
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function computeBuyHoldEquity(rows, initial = 100000, fee = 0.0005) {
|
| 10 |
+
const data = sanitizeRows(rows)
|
| 11 |
+
if (data.length === 0) return []
|
| 12 |
+
const firstPrice = Number(data[0].price)
|
| 13 |
+
const equity = []
|
| 14 |
+
// open with fee
|
| 15 |
+
let capital = initial * (1 - fee)
|
| 16 |
+
for (let i = 0; i < data.length; i++) {
|
| 17 |
+
const price = Number(data[i].price)
|
| 18 |
+
let value = firstPrice > 0 ? capital * (price / firstPrice) : capital
|
| 19 |
+
if (i === data.length - 1) value = value * (1 - fee) // closing fee
|
| 20 |
+
equity.push(Math.max(0, value))
|
| 21 |
+
}
|
| 22 |
+
return equity
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function computeStrategyEquity(rows, initial = 100000, fee = 0.0005, strategy = 'long_short', tradingMode = 'normal') {
|
| 26 |
+
const data = sanitizeRows(rows)
|
| 27 |
+
if (data.length === 0) return []
|
| 28 |
+
let capital = initial
|
| 29 |
+
let position = 'FLAT' // 'LONG' | 'SHORT' | 'FLAT'
|
| 30 |
+
let entryPrice = 0
|
| 31 |
+
const series = []
|
| 32 |
+
for (let i = 0; i < data.length; i++) {
|
| 33 |
+
const { price } = data[i]
|
| 34 |
+
let dailyCapital = capital
|
| 35 |
+
if (position === 'LONG') dailyCapital = entryPrice ? capital * (price / entryPrice) : capital
|
| 36 |
+
else if (position === 'SHORT') dailyCapital = entryPrice ? capital * (1 + (entryPrice - price) / entryPrice) : capital
|
| 37 |
+
|
| 38 |
+
const action = String(data[i].recommended_action || 'HOLD').toUpperCase()
|
| 39 |
+
if (tradingMode === 'normal') {
|
| 40 |
+
if (action === 'BUY') {
|
| 41 |
+
if (position === 'FLAT') { position = 'LONG'; entryPrice = price; capital *= (1 - fee); dailyCapital = capital }
|
| 42 |
+
else if (position === 'SHORT') {
|
| 43 |
+
const ret = entryPrice ? (entryPrice - price) / entryPrice : 0
|
| 44 |
+
capital *= (1 + ret) * (1 - fee)
|
| 45 |
+
position = 'LONG'; entryPrice = price; capital *= (1 - fee); dailyCapital = capital
|
| 46 |
+
}
|
| 47 |
+
} else if (action === 'SELL') {
|
| 48 |
+
if (position === 'LONG') {
|
| 49 |
+
const ret = entryPrice ? (price - entryPrice) / entryPrice : 0
|
| 50 |
+
capital *= (1 + ret) * (1 - fee); position = 'FLAT'; entryPrice = 0; dailyCapital = capital
|
| 51 |
+
} else if (position === 'FLAT' && strategy === 'long_short') {
|
| 52 |
+
position = 'SHORT'; entryPrice = price; capital *= (1 - fee); dailyCapital = capital
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
// HOLD keeps position
|
| 56 |
+
} else { // aggressive: HOLD forces flat, BUY/SELL switch directly
|
| 57 |
+
if (action === 'HOLD') {
|
| 58 |
+
if (position === 'LONG') {
|
| 59 |
+
const ret = entryPrice ? (price - entryPrice) / entryPrice : 0
|
| 60 |
+
capital *= (1 + ret) * (1 - fee); position = 'FLAT'; entryPrice = 0; dailyCapital = capital
|
| 61 |
+
} else if (position === 'SHORT') {
|
| 62 |
+
const ret = entryPrice ? (entryPrice - price) / entryPrice : 0
|
| 63 |
+
capital *= (1 + ret) * (1 - fee); position = 'FLAT'; entryPrice = 0; dailyCapital = capital
|
| 64 |
+
}
|
| 65 |
+
} else if (action === 'BUY') {
|
| 66 |
+
if (position === 'SHORT') {
|
| 67 |
+
const ret = entryPrice ? (entryPrice - price) / entryPrice : 0
|
| 68 |
+
capital *= (1 + ret) * (1 - fee); position = 'FLAT'; entryPrice = 0; dailyCapital = capital
|
| 69 |
+
}
|
| 70 |
+
if (position === 'FLAT') { position = 'LONG'; entryPrice = price; capital *= (1 - fee); dailyCapital = capital }
|
| 71 |
+
} else if (action === 'SELL') {
|
| 72 |
+
if (position === 'LONG') {
|
| 73 |
+
const ret = entryPrice ? (price - entryPrice) / entryPrice : 0
|
| 74 |
+
capital *= (1 + ret) * (1 - fee); position = 'FLAT'; entryPrice = 0; dailyCapital = capital
|
| 75 |
+
}
|
| 76 |
+
if (position === 'FLAT' && strategy === 'long_short') { position = 'SHORT'; entryPrice = price; capital *= (1 - fee); dailyCapital = capital }
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
series.push(dailyCapital)
|
| 80 |
+
if (i === data.length - 1 && position !== 'FLAT') {
|
| 81 |
+
// force close last day
|
| 82 |
+
if (position === 'LONG') { const ret = entryPrice ? (data[i].price - entryPrice) / entryPrice : 0; capital *= (1 + ret) * (1 - fee) }
|
| 83 |
+
else if (position === 'SHORT') { const ret = entryPrice ? (entryPrice - data[i].price) / entryPrice : 0; capital *= (1 + ret) * (1 - fee) }
|
| 84 |
+
series[series.length - 1] = capital
|
| 85 |
+
position = 'FLAT'; entryPrice = 0
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
return series
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function pctChangeSeries(series) {
|
| 92 |
+
const pct = []
|
| 93 |
+
for (let i = 0; i < series.length; i++) {
|
| 94 |
+
if (i === 0) pct.push(0)
|
| 95 |
+
else pct.push(series[i - 1] ? (series[i] - series[i - 1]) / series[i - 1] : 0)
|
| 96 |
+
}
|
| 97 |
+
return pct
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export function calculateMetricsFromSeries(series, assetType = 'stock') {
|
| 101 |
+
if (!series || series.length === 0) return { total_return: 0, ann_return: 0, ann_vol: 0, sharpe_ratio: 0, max_drawdown: 0 }
|
| 102 |
+
const total_return = ((series[series.length - 1] - series[0]) / series[0]) * 100
|
| 103 |
+
const daily = pctChangeSeries(series)
|
| 104 |
+
let annualDays = assetType === 'crypto' ? 365 : 252
|
| 105 |
+
let baseReturns = daily
|
| 106 |
+
if (assetType !== 'crypto') {
|
| 107 |
+
const eff = daily.filter(v => Math.abs(v) > 1e-12)
|
| 108 |
+
baseReturns = eff.length > 0 ? eff : daily
|
| 109 |
+
}
|
| 110 |
+
const mean = baseReturns.length > 0 ? baseReturns.reduce((a, b) => a + b, 0) / baseReturns.length : 0
|
| 111 |
+
const variance = baseReturns.length > 1 ? baseReturns.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (baseReturns.length - 1) : 0
|
| 112 |
+
const std = Math.sqrt(variance)
|
| 113 |
+
const ann_vol = std * Math.sqrt(annualDays) * 100
|
| 114 |
+
const n = baseReturns.length > 0 ? baseReturns.length : daily.length
|
| 115 |
+
const ann_return = n > 1 ? ((Math.pow(series[series.length - 1] / series[0], annualDays / n) - 1) * 100) : total_return
|
| 116 |
+
// sharpe (rf=0)
|
| 117 |
+
const sharpe_ratio = std > 0 ? (mean / std) * Math.sqrt(annualDays) : 0
|
| 118 |
+
// max drawdown
|
| 119 |
+
let peak = -Infinity
|
| 120 |
+
let maxDd = 0
|
| 121 |
+
for (const v of series) {
|
| 122 |
+
peak = Math.max(peak, v)
|
| 123 |
+
maxDd = Math.min(maxDd, (v - peak) / peak)
|
| 124 |
+
}
|
| 125 |
+
return { total_return, ann_return, ann_vol, sharpe_ratio, max_drawdown: maxDd * 100 }
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export function computeWinRate(rows, strategy = 'long_short', tradingMode = 'normal') {
|
| 129 |
+
const data = sanitizeRows(rows)
|
| 130 |
+
if (data.length === 0) return { winRate: 0, trades: 0 }
|
| 131 |
+
let position = 'FLAT'
|
| 132 |
+
let entryPrice = 0
|
| 133 |
+
let wins = 0
|
| 134 |
+
let trades = 0
|
| 135 |
+
for (let i = 0; i < data.length; i++) {
|
| 136 |
+
const { price } = data[i]
|
| 137 |
+
const action = String(data[i].recommended_action || 'HOLD').toUpperCase()
|
| 138 |
+
if (tradingMode === 'normal') {
|
| 139 |
+
if (action === 'BUY') {
|
| 140 |
+
if (position === 'SHORT') { const pnl = entryPrice - price; wins += pnl > 0 ? 1 : 0; trades++; position = 'LONG'; entryPrice = price }
|
| 141 |
+
else if (position === 'FLAT') { position = 'LONG'; entryPrice = price }
|
| 142 |
+
} else if (action === 'SELL') {
|
| 143 |
+
if (position === 'LONG') { const pnl = price - entryPrice; wins += pnl > 0 ? 1 : 0; trades++; position = 'FLAT'; entryPrice = 0 }
|
| 144 |
+
else if (position === 'FLAT' && strategy === 'long_short') { position = 'SHORT'; entryPrice = price }
|
| 145 |
+
}
|
| 146 |
+
} else {
|
| 147 |
+
if (action === 'HOLD') {
|
| 148 |
+
if (position === 'LONG') { const pnl = price - entryPrice; wins += pnl > 0 ? 1 : 0; trades++; position = 'FLAT'; entryPrice = 0 }
|
| 149 |
+
else if (position === 'SHORT') { const pnl = entryPrice - price; wins += pnl > 0 ? 1 : 0; trades++; position = 'FLAT'; entryPrice = 0 }
|
| 150 |
+
} else if (action === 'BUY') {
|
| 151 |
+
if (position === 'SHORT') { const pnl = entryPrice - price; wins += pnl > 0 ? 1 : 0; trades++; position = 'FLAT'; entryPrice = 0 }
|
| 152 |
+
if (position === 'FLAT') { position = 'LONG'; entryPrice = price }
|
| 153 |
+
} else if (action === 'SELL') {
|
| 154 |
+
if (position === 'LONG') { const pnl = price - entryPrice; wins += pnl > 0 ? 1 : 0; trades++; position = 'FLAT'; entryPrice = 0 }
|
| 155 |
+
if (position === 'FLAT' && strategy === 'long_short') { position = 'SHORT'; entryPrice = price }
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
// force close last position
|
| 160 |
+
if (position !== 'FLAT' && data.length > 0) {
|
| 161 |
+
const lastPrice = Number(data[data.length - 1].price)
|
| 162 |
+
if (position === 'LONG') {
|
| 163 |
+
const pnl = lastPrice - entryPrice
|
| 164 |
+
wins += pnl > 0 ? 1 : 0
|
| 165 |
+
trades++
|
| 166 |
+
} else if (position === 'SHORT') {
|
| 167 |
+
const pnl = entryPrice - lastPrice
|
| 168 |
+
wins += pnl > 0 ? 1 : 0
|
| 169 |
+
trades++
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
const winRate = trades > 0 ? Math.round((wins / trades) * 100) : 0
|
| 173 |
+
return { winRate, trades }
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
|
src/lib/strategies.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Central strategy registry used by the UI to compute and display multiple strategies per agent.
|
| 2 |
+
// Each strategy config maps to computeStrategyEquity/computeWinRate arguments.
|
| 3 |
+
|
| 4 |
+
export const STRATEGIES = [
|
| 5 |
+
// { id: 'baseline_ls', label: 'Baseline L/S', strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 },
|
| 6 |
+
// { id: 'aggressive_ls', label: 'Aggressive L/S', strategy: 'long_short', tradingMode: 'aggressive', fee: 0.0005 },
|
| 7 |
+
// { id: 'baseline_lo', label: 'Baseline Long Only', strategy: 'long_only', tradingMode: 'normal', fee: 0.0005 },
|
| 8 |
+
{ id: 'aggressive_lo', label: 'Aggressive Long Only', strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
|
| 9 |
+
]
|
| 10 |
+
|
| 11 |
+
|
src/lib/supabase.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createClient } from '@supabase/supabase-js'
|
| 2 |
+
|
| 3 |
+
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
| 4 |
+
const anon = import.meta.env.VITE_SUPABASE_ANON_KEY
|
| 5 |
+
const service = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
| 6 |
+
// For this visualization app, prefer service key if provided to bypass RLS for read-only endpoints
|
| 7 |
+
const clientKey = service || anon
|
| 8 |
+
|
| 9 |
+
export const supabase = createClient(supabaseUrl, clientKey, {
|
| 10 |
+
auth: { persistSession: false }
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
// Helper to run GraphQL queries for features not available in REST
|
| 14 |
+
export async function graphql(query, variables) {
|
| 15 |
+
const apiKey = service || anon
|
| 16 |
+
const res = await fetch(`${supabaseUrl}/graphql/v1`, {
|
| 17 |
+
method: 'POST',
|
| 18 |
+
headers: {
|
| 19 |
+
'content-type': 'application/json',
|
| 20 |
+
'apikey': apiKey,
|
| 21 |
+
'authorization': `Bearer ${apiKey}`
|
| 22 |
+
},
|
| 23 |
+
body: JSON.stringify({ query, variables })
|
| 24 |
+
})
|
| 25 |
+
const json = await res.json()
|
| 26 |
+
if (json.errors) {
|
| 27 |
+
console.error('GraphQL errors:', json.errors)
|
| 28 |
+
}
|
| 29 |
+
return json.data
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
src/main.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createApp } from 'vue'
|
| 2 |
+
import App from './App.vue'
|
| 3 |
+
import router from './router/index.js'
|
| 4 |
+
|
| 5 |
+
// PrimeVue setup
|
| 6 |
+
import PrimeVue from 'primevue/config'
|
| 7 |
+
import Aura from '@primevue/themes/aura'
|
| 8 |
+
import { definePreset } from '@primevue/themes'
|
| 9 |
+
import Button from 'primevue/button'
|
| 10 |
+
import Card from 'primevue/card'
|
| 11 |
+
import Dropdown from 'primevue/dropdown'
|
| 12 |
+
import MultiSelect from 'primevue/multiselect'
|
| 13 |
+
import InputText from 'primevue/inputtext'
|
| 14 |
+
import InputSwitch from 'primevue/inputswitch'
|
| 15 |
+
import TabView from 'primevue/tabview'
|
| 16 |
+
import TabPanel from 'primevue/tabpanel'
|
| 17 |
+
import DataTable from 'primevue/datatable'
|
| 18 |
+
import Column from 'primevue/column'
|
| 19 |
+
import ProgressSpinner from 'primevue/progressspinner'
|
| 20 |
+
import Checkbox from 'primevue/checkbox'
|
| 21 |
+
import Divider from 'primevue/divider'
|
| 22 |
+
import DatePicker from 'primevue/datepicker'
|
| 23 |
+
import Slider from 'primevue/slider'
|
| 24 |
+
import Tooltip from 'primevue/tooltip'
|
| 25 |
+
import Drawer from 'primevue/drawer'
|
| 26 |
+
import Tag from 'primevue/tag'
|
| 27 |
+
import emailjs from 'emailjs-com'
|
| 28 |
+
|
| 29 |
+
// Styles
|
| 30 |
+
import 'primeicons/primeicons.css'
|
| 31 |
+
import 'primeflex/primeflex.css'
|
| 32 |
+
// PrimeVue v4 theme preset (Aura - slate)
|
| 33 |
+
const AuraSlate = definePreset(Aura, {
|
| 34 |
+
semantic: {
|
| 35 |
+
primary: {
|
| 36 |
+
50: '{slate.50}',
|
| 37 |
+
100: '{slate.100}',
|
| 38 |
+
200: '{slate.200}',
|
| 39 |
+
300: '{slate.300}',
|
| 40 |
+
400: '{slate.400}',
|
| 41 |
+
500: '{slate.500}',
|
| 42 |
+
600: '{slate.600}',
|
| 43 |
+
700: '{slate.700}',
|
| 44 |
+
800: '{slate.800}',
|
| 45 |
+
900: '{slate.900}',
|
| 46 |
+
950: '{slate.950}'
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
const app = createApp(App)
|
| 52 |
+
app.use(router)
|
| 53 |
+
app.use(PrimeVue, {
|
| 54 |
+
theme: {
|
| 55 |
+
preset: AuraSlate,
|
| 56 |
+
options: {
|
| 57 |
+
darkModeSelector: '.dark-mode',
|
| 58 |
+
cssLayer: false
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
app.component('Button', Button)
|
| 64 |
+
app.component('Card', Card)
|
| 65 |
+
app.component('Dropdown', Dropdown)
|
| 66 |
+
app.component('MultiSelect', MultiSelect)
|
| 67 |
+
app.component('InputText', InputText)
|
| 68 |
+
app.component('InputSwitch', InputSwitch)
|
| 69 |
+
app.component('TabView', TabView)
|
| 70 |
+
app.component('TabPanel', TabPanel)
|
| 71 |
+
app.component('DataTable', DataTable)
|
| 72 |
+
app.component('Column', Column)
|
| 73 |
+
app.component('ProgressSpinner', ProgressSpinner)
|
| 74 |
+
app.component('Checkbox', Checkbox)
|
| 75 |
+
app.component('Divider', Divider)
|
| 76 |
+
app.component('DatePicker', DatePicker)
|
| 77 |
+
app.component('Slider', Slider)
|
| 78 |
+
app.component('Drawer', Drawer)
|
| 79 |
+
app.component('Tag', Tag)
|
| 80 |
+
|
| 81 |
+
app.directive('tooltip', Tooltip)
|
| 82 |
+
|
| 83 |
+
// Initialize EmailJS using env public key if available
|
| 84 |
+
try {
|
| 85 |
+
const pubKey = import.meta.env.VITE_EMAILJS_PUBLIC_KEY
|
| 86 |
+
if (pubKey) { emailjs.init(pubKey) }
|
| 87 |
+
} catch (_) { }
|
| 88 |
+
|
| 89 |
+
app.mount('#app')
|
src/pages/EquityComparison.vue
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="p-4 flex flex-column gap-3">
|
| 3 |
+
<div class="flex align-items-center justify-content-between mb-2">
|
| 4 |
+
<div class="flex align-items-center gap-2">
|
| 5 |
+
<Button label="← Back" class="p-button-outlined" size="small" rounded @click="$router.back()" />
|
| 6 |
+
<div class="text-500 ml-2">Last updated: {{ lastUpdatedDisplay }}</div>
|
| 7 |
+
</div>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div v-if="loading" class="loading-overlay">
|
| 11 |
+
<div class="loading-box">
|
| 12 |
+
<ProgressSpinner />
|
| 13 |
+
<div class="mt-3 text-600">Loading Equity Curves...</div>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<Card class="card-full">
|
| 18 |
+
<template #title>
|
| 19 |
+
<div class="mb-2 text-900" style="font-size: 20px; font-weight: 600">Equity Curve Comparison</div>
|
| 20 |
+
<Divider />
|
| 21 |
+
</template>
|
| 22 |
+
<template #content>
|
| 23 |
+
<div class="chart-container">
|
| 24 |
+
<canvas ref="canvas"></canvas>
|
| 25 |
+
</div>
|
| 26 |
+
</template>
|
| 27 |
+
</Card>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
</template>
|
| 31 |
+
|
| 32 |
+
<script>
|
| 33 |
+
import { supabase } from '../lib/supabase'
|
| 34 |
+
import { getAllDecisions, setAllDecisions } from '../lib/dataCache'
|
| 35 |
+
import { writeRawDecisions, clearAllStores, readAllRawDecisions } from '../lib/idb'
|
| 36 |
+
import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
|
| 37 |
+
import { STRATEGIES } from '../lib/strategies'
|
| 38 |
+
import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
|
| 39 |
+
import { Chart, LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip } from 'chart.js'
|
| 40 |
+
|
| 41 |
+
const vLinePlugin = {
|
| 42 |
+
id: 'vLinePlugin',
|
| 43 |
+
afterDatasetsDraw(chart, args, pluginOptions) {
|
| 44 |
+
const active = (typeof chart.getActiveElements === 'function') ? chart.getActiveElements() : (chart.tooltip && chart.tooltip._active) || []
|
| 45 |
+
if (!active || !active.length) return
|
| 46 |
+
const { datasetIndex, index } = active[0]
|
| 47 |
+
const meta = chart.getDatasetMeta(datasetIndex)
|
| 48 |
+
const pt = meta && meta.data && meta.data[index]
|
| 49 |
+
if (!pt) return
|
| 50 |
+
const x = pt.x
|
| 51 |
+
const { top, bottom } = chart.chartArea
|
| 52 |
+
const ctx = chart.ctx
|
| 53 |
+
ctx.save()
|
| 54 |
+
ctx.beginPath()
|
| 55 |
+
ctx.moveTo(x, top)
|
| 56 |
+
ctx.lineTo(x, bottom)
|
| 57 |
+
ctx.lineWidth = (pluginOptions && pluginOptions.lineWidth) || 1
|
| 58 |
+
ctx.strokeStyle = (pluginOptions && pluginOptions.color) || 'rgba(0,0,0,0.35)'
|
| 59 |
+
ctx.setLineDash((pluginOptions && pluginOptions.dash) || [4, 4])
|
| 60 |
+
ctx.stroke()
|
| 61 |
+
ctx.restore()
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
Chart.register(LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip, vLinePlugin)
|
| 66 |
+
|
| 67 |
+
function color(i) {
|
| 68 |
+
const colors = ['#3b82f6', '#22c55e', '#ef4444', '#a855f7', '#f59e0b', '#06b6d4', '#16a34a', '#f97316', '#0ea5e9', '#d946ef']
|
| 69 |
+
return colors[i % colors.length]
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export default {
|
| 73 |
+
name: 'EquityComparison',
|
| 74 |
+
components: { },
|
| 75 |
+
data() {
|
| 76 |
+
return {
|
| 77 |
+
loading: true,
|
| 78 |
+
lastUpdated: null,
|
| 79 |
+
groupSeqMap: new Map(),
|
| 80 |
+
datasets: [],
|
| 81 |
+
chart: null,
|
| 82 |
+
labels: []
|
| 83 |
+
}
|
| 84 |
+
},
|
| 85 |
+
computed: {
|
| 86 |
+
lastUpdatedDisplay() {
|
| 87 |
+
return this.lastUpdated ? new Date(this.lastUpdated).toLocaleString() : '-'
|
| 88 |
+
}
|
| 89 |
+
},
|
| 90 |
+
watch: {},
|
| 91 |
+
methods: {
|
| 92 |
+
async forceRefresh(){
|
| 93 |
+
this.loading = true
|
| 94 |
+
try { await clearAllStores() } catch(_) {}
|
| 95 |
+
await this.load(true)
|
| 96 |
+
},
|
| 97 |
+
async load(forceRemote = false) {
|
| 98 |
+
this.loading = true
|
| 99 |
+
let all = (!forceRemote ? getAllDecisions() : null)
|
| 100 |
+
if (!all && !forceRemote) {
|
| 101 |
+
try {
|
| 102 |
+
const cached = await readAllRawDecisions()
|
| 103 |
+
if (cached && cached.length) {
|
| 104 |
+
all = cached
|
| 105 |
+
setAllDecisions(all)
|
| 106 |
+
}
|
| 107 |
+
} catch(_) {}
|
| 108 |
+
}
|
| 109 |
+
if (!all) {
|
| 110 |
+
console.log('[EquityComparison] pulling all from remote...')
|
| 111 |
+
const pageSize = 1000
|
| 112 |
+
let from = 0
|
| 113 |
+
all = []
|
| 114 |
+
while (true) {
|
| 115 |
+
const to = from + pageSize - 1
|
| 116 |
+
const { data, error } = await supabase
|
| 117 |
+
.from('trading_decisions')
|
| 118 |
+
.select('id, agent_name, asset, model, date, price, recommended_action')
|
| 119 |
+
.order('updated_at', { ascending: false })
|
| 120 |
+
.range(from, to)
|
| 121 |
+
if (error) { console.error(error); break }
|
| 122 |
+
all = all.concat(data || [])
|
| 123 |
+
if (!data || data.length < pageSize) break
|
| 124 |
+
from += pageSize
|
| 125 |
+
}
|
| 126 |
+
setAllDecisions(all)
|
| 127 |
+
try { await writeRawDecisions(all) } catch(_) {}
|
| 128 |
+
}
|
| 129 |
+
console.log('[EquityComparison] all decisions size:', (all || []).length)
|
| 130 |
+
|
| 131 |
+
// 构建每个 (name|asset|model) 的时间序列(按资产处理交易日)
|
| 132 |
+
const keyToSeq = new Map()
|
| 133 |
+
const groups = new Map()
|
| 134 |
+
for (const row of all) {
|
| 135 |
+
if (row.price == null || !row.date) continue
|
| 136 |
+
const key = `${row.agent_name}|${row.asset}|${row.model}`
|
| 137 |
+
if (!groups.has(key)) groups.set(key, [])
|
| 138 |
+
groups.get(key).push(row)
|
| 139 |
+
}
|
| 140 |
+
console.log('[EquityComparison] groups count:', groups.size)
|
| 141 |
+
for (const [key, list] of groups.entries()) {
|
| 142 |
+
list.sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 143 |
+
const asset = list[0]?.asset
|
| 144 |
+
const isCrypto = asset === 'BTC' || asset === 'ETH'
|
| 145 |
+
const seq = isCrypto ? list : (await filterRowsToNyseTradingDays(list))
|
| 146 |
+
keyToSeq.set(key, seq)
|
| 147 |
+
}
|
| 148 |
+
this.groupSeqMap = keyToSeq
|
| 149 |
+
|
| 150 |
+
// 根据 Home 中选择的行构建数据集(内部会在数据就绪后触发 draw)
|
| 151 |
+
this.rebuildDatasetsFromSelection()
|
| 152 |
+
this.lastUpdated = Date.now()
|
| 153 |
+
this.loading = false
|
| 154 |
+
},
|
| 155 |
+
rebuildDatasetsFromSelection(){
|
| 156 |
+
const ds = []
|
| 157 |
+
let selected = []
|
| 158 |
+
try { selected = JSON.parse(sessionStorage.getItem('compareRows') || '[]') } catch(_) {}
|
| 159 |
+
if (!Array.isArray(selected)) selected = []
|
| 160 |
+
console.log('[EquityComparison] compareRows:', selected)
|
| 161 |
+
|
| 162 |
+
const all = getAllDecisions() || []
|
| 163 |
+
console.log('[EquityComparison] getAllDecisions size:', all.length)
|
| 164 |
+
|
| 165 |
+
// Build sequences using PerformanceChart's approach: use decision_ids first, else fallback triple key
|
| 166 |
+
const groupKeyToSeq = new Map()
|
| 167 |
+
for (const sel of selected) {
|
| 168 |
+
const agent = sel.agent_name
|
| 169 |
+
const asset = sel.asset
|
| 170 |
+
const model = sel.model
|
| 171 |
+
const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
|
| 172 |
+
let seq = []
|
| 173 |
+
if (ids.length) {
|
| 174 |
+
seq = all.filter(r => ids.includes(r.id))
|
| 175 |
+
} else {
|
| 176 |
+
seq = all.filter(r => r.agent_name === agent && r.asset === asset && r.model === model)
|
| 177 |
+
}
|
| 178 |
+
console.log('[EquityComparison] seq len (before filter)', `${agent}|${asset}|${model}`, seq.length)
|
| 179 |
+
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
|
| 180 |
+
const isCrypto = asset === 'BTC' || asset === 'ETH'
|
| 181 |
+
const filteredSeqPromise = isCrypto ? Promise.resolve(seq) : filterRowsToNyseTradingDays(seq)
|
| 182 |
+
groupKeyToSeq.set(`${agent}|${asset}|${model}`, filteredSeqPromise)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Resolve any pending filtered sequences (stocks)
|
| 186 |
+
const entries = Array.from(groupKeyToSeq.entries())
|
| 187 |
+
// Await all promises for stock filtering
|
| 188 |
+
// Note: some entries hold raw arrays (crypto), unify via Promise.resolve
|
| 189 |
+
Promise.all(entries.map(([k, v]) => (Array.isArray(v) ? Promise.resolve([k, v]) : v.then(arr => [k, arr])))).then(resolved => {
|
| 190 |
+
const resolvedMap = new Map(resolved)
|
| 191 |
+
console.log('[EquityComparison] resolved groups:', Array.from(resolvedMap.keys()))
|
| 192 |
+
console.log('[EquityComparison] resolved sizes:', Array.from(resolvedMap.entries()).map(([k,v]) => [k, v.length]))
|
| 193 |
+
// Construct datasets per selected row with strategy from STRATEGIES matching sel.strategy
|
| 194 |
+
let lineIndex = 0
|
| 195 |
+
const selectedAssets = new Set()
|
| 196 |
+
// build labels from the first resolved sequence's dates
|
| 197 |
+
this.labels = []
|
| 198 |
+
if (selected && selected.length) {
|
| 199 |
+
const first = selected[0]
|
| 200 |
+
const gk0 = `${first.agent_name}|${first.asset}|${first.model}`
|
| 201 |
+
const seq0 = resolvedMap.get(gk0) || []
|
| 202 |
+
this.labels = (seq0 || []).map(s => s.date)
|
| 203 |
+
}
|
| 204 |
+
for (const sel of selected) {
|
| 205 |
+
const agent = sel.agent_name
|
| 206 |
+
const asset = sel.asset
|
| 207 |
+
const model = sel.model
|
| 208 |
+
const gk = `${agent}|${asset}|${model}`
|
| 209 |
+
const seq = resolvedMap.get(gk) || []
|
| 210 |
+
if (!seq.length) continue
|
| 211 |
+
selectedAssets.add(asset)
|
| 212 |
+
// Pick strategy by sel.strategy id; if empty, default long_short normal
|
| 213 |
+
const strategyCfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005, label: 'Selected' }
|
| 214 |
+
const series = computeStrategyEquity(seq, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode)
|
| 215 |
+
console.log('[EquityComparison] series len', gk, strategyCfg.id || strategyCfg.label, series.length)
|
| 216 |
+
if (!series || series.length === 0) continue
|
| 217 |
+
ds.push({
|
| 218 |
+
label: `${agent}|${asset}|${model}|${strategyCfg.label || sel.strategy || 'Strategy'}`,
|
| 219 |
+
data: this.labels && this.labels.length ? series.slice(0, this.labels.length) : series,
|
| 220 |
+
borderColor: color(lineIndex++),
|
| 221 |
+
pointRadius: 0,
|
| 222 |
+
tension: 0.15
|
| 223 |
+
})
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// Add one Buy&Hold per selected asset
|
| 227 |
+
const seenAsset = new Set()
|
| 228 |
+
for (const [gk, seq] of resolvedMap.entries()) {
|
| 229 |
+
const asset = gk.split('|')[1]
|
| 230 |
+
if (!selectedAssets.has(asset)) continue
|
| 231 |
+
if (seenAsset.has(asset)) continue
|
| 232 |
+
seenAsset.add(asset)
|
| 233 |
+
const bh = computeBuyHoldEquity(seq, 100000)
|
| 234 |
+
console.log('[EquityComparison] baseline len', asset, bh.length)
|
| 235 |
+
if (!bh || bh.length === 0) continue
|
| 236 |
+
ds.push({
|
| 237 |
+
label: `${asset} · Buy&Hold`,
|
| 238 |
+
data: this.labels && this.labels.length ? bh.slice(0, this.labels.length) : bh,
|
| 239 |
+
borderColor: color(lineIndex++),
|
| 240 |
+
borderDash: [6, 4],
|
| 241 |
+
borderWidth: 1.5,
|
| 242 |
+
pointRadius: 0,
|
| 243 |
+
tension: 0
|
| 244 |
+
})
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
this.datasets = ds
|
| 248 |
+
console.log('[EquityComparison] datasets count:', ds.length)
|
| 249 |
+
this.draw()
|
| 250 |
+
})
|
| 251 |
+
},
|
| 252 |
+
draw(){
|
| 253 |
+
if (!this.$refs.canvas) return
|
| 254 |
+
this.$nextTick(() => {
|
| 255 |
+
if (!this.$refs.canvas) return
|
| 256 |
+
if (this.chart) { try{ this.chart.destroy() }catch(_){}; this.chart = null }
|
| 257 |
+
// ensure canvas sized to container
|
| 258 |
+
try {
|
| 259 |
+
const parent = this.$refs.canvas.parentElement
|
| 260 |
+
if (parent) {
|
| 261 |
+
// ensure parent has non-zero height even inside Card
|
| 262 |
+
if (!parent.clientHeight || parent.clientHeight < 50) {
|
| 263 |
+
parent.style.minHeight = '560px'
|
| 264 |
+
parent.style.height = '560px'
|
| 265 |
+
}
|
| 266 |
+
const w = parent.clientWidth || 800
|
| 267 |
+
const h = parent.clientHeight || 560
|
| 268 |
+
this.$refs.canvas.style.width = '100%'
|
| 269 |
+
this.$refs.canvas.style.height = '100%'
|
| 270 |
+
this.$refs.canvas.width = w
|
| 271 |
+
this.$refs.canvas.height = h
|
| 272 |
+
}
|
| 273 |
+
} catch(_) {}
|
| 274 |
+
|
| 275 |
+
// use previously computed date labels; fallback to indices
|
| 276 |
+
let labels = Array.isArray(this.labels) && this.labels.length ? this.labels : []
|
| 277 |
+
if (!labels.length) {
|
| 278 |
+
const maxLen = Math.max(0, ...this.datasets.map(d => (Array.isArray(d.data) ? d.data.length : 0)))
|
| 279 |
+
labels = Array.from({ length: maxLen }, (_, i) => `${i + 1}`)
|
| 280 |
+
}
|
| 281 |
+
const allValues = this.datasets.flatMap(d => (Array.isArray(d.data) ? d.data : []))
|
| 282 |
+
const minV = allValues.length ? Math.min(...allValues) : 0
|
| 283 |
+
const maxV = allValues.length ? Math.max(...allValues) : 1
|
| 284 |
+
const pad = (maxV - minV) * 0.1 || 1000
|
| 285 |
+
const yMin = minV - pad
|
| 286 |
+
const yMax = maxV + pad
|
| 287 |
+
const ctx = this.$refs.canvas.getContext('2d')
|
| 288 |
+
this.chart = new Chart(ctx, {
|
| 289 |
+
type: 'line',
|
| 290 |
+
data: { labels, datasets: this.datasets },
|
| 291 |
+
options: {
|
| 292 |
+
responsive: true,
|
| 293 |
+
maintainAspectRatio: false,
|
| 294 |
+
animation: false,
|
| 295 |
+
interaction: { mode: 'index', intersect: false },
|
| 296 |
+
plugins: {
|
| 297 |
+
legend: {
|
| 298 |
+
display: true,
|
| 299 |
+
position: 'left',
|
| 300 |
+
labels: { usePointStyle: true, boxWidth: 8 },
|
| 301 |
+
},
|
| 302 |
+
vLinePlugin: { color: 'rgba(0,0,0,0.35)', lineWidth: 1, dash: [4,4] }
|
| 303 |
+
},
|
| 304 |
+
scales: {
|
| 305 |
+
x: { type: 'category', ticks: { autoSkip: true, maxTicksLimit: 10 } },
|
| 306 |
+
y: { min: yMin, max: yMax }
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
})
|
| 310 |
+
})
|
| 311 |
+
}
|
| 312 |
+
},
|
| 313 |
+
mounted() { this.load() }
|
| 314 |
+
}
|
| 315 |
+
</script>
|
| 316 |
+
|
| 317 |
+
<style scoped>
|
| 318 |
+
.loading-overlay {
|
| 319 |
+
position: fixed;
|
| 320 |
+
inset: 0;
|
| 321 |
+
background: rgba(255, 255, 255, 0.85);
|
| 322 |
+
z-index: 1000;
|
| 323 |
+
display: flex;
|
| 324 |
+
align-items: center;
|
| 325 |
+
justify-content: center;
|
| 326 |
+
}
|
| 327 |
+
.loading-box { text-align: center; }
|
| 328 |
+
|
| 329 |
+
.chart-container {
|
| 330 |
+
position: relative;
|
| 331 |
+
height: 560px;
|
| 332 |
+
display: flex;
|
| 333 |
+
}
|
| 334 |
+
.chart-container > canvas {
|
| 335 |
+
width: 100% !important;
|
| 336 |
+
height: 100% !important;
|
| 337 |
+
}
|
| 338 |
+
.card-full { width: 100%; display: flex; flex-direction: column; }
|
| 339 |
+
.card-full :deep(.p-card-body) { display: flex; flex-direction: column; height: 100%; }
|
| 340 |
+
.card-full :deep(.p-card-content) { flex: 1; display: flex; flex-direction: column; }
|
| 341 |
+
.empty-offset { flex: 1; min-height: 400px; position: relative; }
|
| 342 |
+
.empty-offset > span {
|
| 343 |
+
position: absolute;
|
| 344 |
+
left: 50%;
|
| 345 |
+
top: 50%;
|
| 346 |
+
transform: translate(-50%, -50%);
|
| 347 |
+
text-align: center;
|
| 348 |
+
}
|
| 349 |
+
</style>
|
| 350 |
+
|
src/pages/Main.vue
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="main-container">
|
| 3 |
+
<Header />
|
| 4 |
+
<router-view />
|
| 5 |
+
<Footer />
|
| 6 |
+
</div>
|
| 7 |
+
</template>
|
| 8 |
+
|
| 9 |
+
<script>
|
| 10 |
+
import Header from '../components/Header.vue'
|
| 11 |
+
import Footer from '../components/Footer.vue'
|
| 12 |
+
export default {
|
| 13 |
+
name: 'Main',
|
| 14 |
+
components: {
|
| 15 |
+
Header,
|
| 16 |
+
Footer
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
</script>
|
| 20 |
+
|
| 21 |
+
<style scoped>
|
| 22 |
+
.main-container {
|
| 23 |
+
width: 100%;
|
| 24 |
+
height: 100%;
|
| 25 |
+
}
|
| 26 |
+
</style>
|
| 27 |
+
|
src/router/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createRouter, createWebHistory } from 'vue-router'
|
| 2 |
+
import Main from '../pages/Main.vue'
|
| 3 |
+
import LeadboardView from '../views/LeadboardView.vue'
|
| 4 |
+
import LiveView from '../views/LiveView.vue'
|
| 5 |
+
import AddAssetsView from '../views/AddAssetView.vue'
|
| 6 |
+
import { dataService } from '../lib/dataService.js'
|
| 7 |
+
|
| 8 |
+
const routes = [
|
| 9 |
+
{
|
| 10 |
+
path: '/',
|
| 11 |
+
name: 'main',
|
| 12 |
+
component: Main,
|
| 13 |
+
redirect: '/leadboard',
|
| 14 |
+
children: [
|
| 15 |
+
{ path: '/leadboard', name: 'leadboard', component: LeadboardView },
|
| 16 |
+
{ path: '/live', name: 'live', component: LiveView },
|
| 17 |
+
{ path: '/add-asset', name: 'add-asset', component: AddAssetsView },
|
| 18 |
+
]
|
| 19 |
+
}
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
const router = createRouter({
|
| 23 |
+
history: createWebHistory(),
|
| 24 |
+
routes
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
// 全局路由守卫:确保数据在导航前开始加载
|
| 28 |
+
router.beforeEach(async (to, from, next) => {
|
| 29 |
+
// 如果数据还未加载且不在加载中,则触发加载
|
| 30 |
+
if (!dataService.loaded && !dataService.loading) {
|
| 31 |
+
console.log('[Router] Triggering data load before navigation')
|
| 32 |
+
// 不等待加载完成,让加载在后台进行
|
| 33 |
+
dataService.load().catch(e => {
|
| 34 |
+
console.error('[Router] Error loading data:', e)
|
| 35 |
+
})
|
| 36 |
+
}
|
| 37 |
+
next()
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
export default router
|
| 41 |
+
|
| 42 |
+
|
src/views/AddAssetView.vue
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div>
|
| 3 |
+
<h1>Add Assets</h1>
|
| 4 |
+
</div>
|
| 5 |
+
</template>
|
| 6 |
+
|
| 7 |
+
<script>
|
| 8 |
+
export default {
|
| 9 |
+
name: 'AddAssetsView'
|
| 10 |
+
}
|
| 11 |
+
</script>
|
src/views/LeadboardView.vue
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div>
|
| 3 |
+
<div class="title-container">
|
| 4 |
+
<span class="main-title">Agent Leadboard</span>
|
| 5 |
+
</div>
|
| 6 |
+
<AssetsFilter v-model="filters.assets" :assetOptions="assetOptions" />
|
| 7 |
+
<div v-if="loading" class="loading-overlay">
|
| 8 |
+
<div class="loading-box">
|
| 9 |
+
<ProgressSpinner />
|
| 10 |
+
<div class="mt-3 text-600">Loading Paper Trading Agents...</div>
|
| 11 |
+
</div>
|
| 12 |
+
</div>
|
| 13 |
+
<div class="page-wrapper">
|
| 14 |
+
<div class="p-3 flex flex-column gap-2">
|
| 15 |
+
<!-- Mobile filter button -->
|
| 16 |
+
<div class="mobile-filter-btn-container">
|
| 17 |
+
<Button icon="pi pi-filter" label="Filters" @click="drawerVisible = true" size="small" rounded class="mobile-filter-btn" />
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<!-- Drawer for mobile -->
|
| 21 |
+
<Drawer v-model:visible="drawerVisible" header="Filter Matrix" position="left" class="filter-drawer">
|
| 22 |
+
<AgentFilters v-model="filters" :nameOptions="nameOptions" :assetOptions="assetOptions" :modelOptions="modelOptions" :strategyOptions="strategyOptions" :dateBounds="dateBounds" />
|
| 23 |
+
</Drawer>
|
| 24 |
+
|
| 25 |
+
<div class="flex gap-2 align-items-stretch">
|
| 26 |
+
<!-- Desktop filter panel -->
|
| 27 |
+
<div class="col-panel desktop-filter-panel" style="flex: 1; min-width: 280px;">
|
| 28 |
+
<Card class="mb-2 card-full compact-card content-card">
|
| 29 |
+
<template #title>
|
| 30 |
+
<div class="mb-4 text-900" style="font-size: 20px; font-weight: 600">Filter Matrix</div>
|
| 31 |
+
<Divider />
|
| 32 |
+
</template>
|
| 33 |
+
<template #content>
|
| 34 |
+
<div>
|
| 35 |
+
<AgentFilters v-model="filters" :nameOptions="nameOptions" :assetOptions="assetOptions" :modelOptions="modelOptions" :strategyOptions="strategyOptions" :dateBounds="dateBounds" />
|
| 36 |
+
</div>
|
| 37 |
+
</template>
|
| 38 |
+
</Card>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="col-panel" style="flex: 4; min-width: 0;">
|
| 42 |
+
<Card class="mb-2 card-full compact-card content-card">
|
| 43 |
+
<template #content>
|
| 44 |
+
<div v-if="filteredRows.length === 0" class="empty-offset">
|
| 45 |
+
<span class="text-600">No data found</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div v-else>
|
| 48 |
+
<div class="flex align-items-center justify-content-between mb-2">
|
| 49 |
+
<div class="flex align-items-center gap-2">
|
| 50 |
+
<span class="p-overlay-badge">
|
| 51 |
+
<Button label="Refresh" icon="pi pi-refresh" size="small" rounded @click="forceRefresh()" v-tooltip.right="'Force Refresh, clear all caches and reload from remote'" />
|
| 52 |
+
</span>
|
| 53 |
+
<div class="text-500 ml-2" @click="onUpdatedClick">Last updated: {{ lastUpdatedDisplay }}</div>
|
| 54 |
+
<div class="flex align-items-center gap-2 ml-2" v-if="requestButtonVisible">
|
| 55 |
+
<Button label="Request Assets" class="p-button-outlined" size="small" rounded @click="requestAssetsVisible = true" />
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="flex gap-2">
|
| 59 |
+
<Button v-if="!selectMode" label="Compare" class="p-button-outlined" size="small" rounded @click="enterSelectMode()" />
|
| 60 |
+
<template v-else>
|
| 61 |
+
<Button label="Cancel" class="p-button-text" size="small" rounded severity="secondary" @click="exitSelectMode()" />
|
| 62 |
+
<Button :disabled="selectedRows.length===0" label="Compare Selected" icon="pi pi-chart-line" size="small" rounded @click="openCompareDialog()" />
|
| 63 |
+
</template>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
<AgentTable :rows="filteredRows" :loading="loading" :selectable="selectMode" v-model:selection="selectedRows" />
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
</template>
|
| 70 |
+
</Card>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
<Dialog v-model:visible="compareVisible" modal header="Equity Curve Comparison" style="width: 90vw; max-width: 1200px">
|
| 77 |
+
<div>
|
| 78 |
+
<CompareChart :selected="selectedRows.map(r => ({ agent_name: r.agent_name, asset: r.asset, model: r.model, strategy: r.strategy, decision_ids: r.decision_ids || [] }))" :visible="compareVisible" />
|
| 79 |
+
</div>
|
| 80 |
+
</Dialog>
|
| 81 |
+
<Dialog v-model:visible="requestAssetsVisible" modal header="Request Asset" style="width: 90vw; max-width: 400px">
|
| 82 |
+
<div class="flex justify-content-between gap-2">
|
| 83 |
+
<InputText v-model="asset" placeholder="Asset need adding..." />
|
| 84 |
+
<Button label="Request" size="small" rounded @click="requestAsset()" :disabled="!asset" />
|
| 85 |
+
</div>
|
| 86 |
+
</Dialog>
|
| 87 |
+
</template>
|
| 88 |
+
|
| 89 |
+
<script>
|
| 90 |
+
import { dataService } from '../lib/dataService.js'
|
| 91 |
+
import AgentTable from '../components/AgentTable.vue'
|
| 92 |
+
import AgentFilters from '../components/AgentFilters.vue'
|
| 93 |
+
import AssetsFilter from '../components/AssetsFilter.vue'
|
| 94 |
+
import CompareChart from '../components/CompareChart.vue'
|
| 95 |
+
import InputText from 'primevue/inputtext'
|
| 96 |
+
import Dialog from 'primevue/dialog'
|
| 97 |
+
import { countNonTradingDaysBetweenForAsset, countTradingDaysBetweenForAsset } from '../lib/marketCalendar.js'
|
| 98 |
+
import { computeBuyHoldEquity, computeStrategyEquity, calculateMetricsFromSeries, computeWinRate } from '../lib/perf.js'
|
| 99 |
+
import { STRATEGIES } from '../lib/strategies.js'
|
| 100 |
+
import emailjs from 'emailjs-com'
|
| 101 |
+
|
| 102 |
+
export default {
|
| 103 |
+
name: 'LeadboardView',
|
| 104 |
+
components: { AgentTable, AgentFilters, AssetsFilter, CompareChart, Dialog, InputText },
|
| 105 |
+
data() {
|
| 106 |
+
return {
|
| 107 |
+
loading: true,
|
| 108 |
+
agents: [],
|
| 109 |
+
tableRows: [],
|
| 110 |
+
lastUpdated: null,
|
| 111 |
+
filters: { names: [], assets: [], models: [], strategies: [], dates: [] },
|
| 112 |
+
dateBounds: { min: null, max: null },
|
| 113 |
+
dateFilteredRows: [],
|
| 114 |
+
nameOptions: [],
|
| 115 |
+
assetOptions: [],
|
| 116 |
+
modelOptions: [],
|
| 117 |
+
strategyOptions: [],
|
| 118 |
+
selectMode: false,
|
| 119 |
+
selectedRows: [],
|
| 120 |
+
compareVisible: false,
|
| 121 |
+
requestAssetsVisible: false,
|
| 122 |
+
requestButtonVisible: false,
|
| 123 |
+
updatedClickCount: 0,
|
| 124 |
+
asset: '',
|
| 125 |
+
drawerVisible: false,
|
| 126 |
+
unsubscribe: null
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
watch: {
|
| 130 |
+
// persist filters to sessionStorage for cross-page initialization
|
| 131 |
+
filters: {
|
| 132 |
+
deep: true,
|
| 133 |
+
handler(val){
|
| 134 |
+
try { sessionStorage.setItem('homeFilters', JSON.stringify(val || {})) } catch(_) {}
|
| 135 |
+
// if filters change while comparing, reset to initial state
|
| 136 |
+
if (this.selectMode || this.compareVisible) {
|
| 137 |
+
this.exitSelectMode()
|
| 138 |
+
}
|
| 139 |
+
// recompute on date range change
|
| 140 |
+
try {
|
| 141 |
+
const ds = (val && Array.isArray(val.dates)) ? val.dates : []
|
| 142 |
+
if (ds.length === 2 && ds[0] && ds[1]) {
|
| 143 |
+
const start = new Date(Math.min(new Date(ds[0]).getTime(), new Date(ds[1]).getTime()))
|
| 144 |
+
const end = new Date(Math.max(new Date(ds[0]).getTime(), new Date(ds[1]).getTime()))
|
| 145 |
+
this.recomputeAllForRange(start, end)
|
| 146 |
+
} else {
|
| 147 |
+
this.dateFilteredRows = []
|
| 148 |
+
}
|
| 149 |
+
} catch(_) {}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
},
|
| 153 |
+
computed: {
|
| 154 |
+
lastUpdatedDisplay() {
|
| 155 |
+
return this.lastUpdated ? new Date(this.lastUpdated).toLocaleString() : '-'
|
| 156 |
+
},
|
| 157 |
+
filteredRows() {
|
| 158 |
+
// base rows come from date-filtered recomputation when a range is active
|
| 159 |
+
const useDateRange = Array.isArray(this.filters.dates) && this.filters.dates.length === 2 && this.filters.dates[0] && this.filters.dates[1]
|
| 160 |
+
let rows = useDateRange ? (this.dateFilteredRows || []) : this.tableRows
|
| 161 |
+
const cols = ['names','assets','models','strategies']
|
| 162 |
+
for (const c of cols) { if (!this.filters[c] || this.filters[c].length === 0) return [] }
|
| 163 |
+
rows = rows.filter(r => this.filters.names.includes(r.agent_name))
|
| 164 |
+
rows = rows.filter(r => this.filters.assets.includes(r.asset))
|
| 165 |
+
rows = rows.filter(r => this.filters.models.includes(r.model))
|
| 166 |
+
rows = rows.filter(r => this.filters.strategies.includes(r.strategy))
|
| 167 |
+
return rows
|
| 168 |
+
}
|
| 169 |
+
},
|
| 170 |
+
methods: {
|
| 171 |
+
/**
|
| 172 |
+
* 从 dataService 同步状态
|
| 173 |
+
*/
|
| 174 |
+
syncFromDataService(state) {
|
| 175 |
+
this.loading = state.loading
|
| 176 |
+
this.agents = state.agents
|
| 177 |
+
this.tableRows = state.tableRows
|
| 178 |
+
this.lastUpdated = state.lastUpdated
|
| 179 |
+
this.dateBounds = state.dateBounds
|
| 180 |
+
|
| 181 |
+
// 只在选项为空时才从服务同步
|
| 182 |
+
if (!this.nameOptions.length) this.nameOptions = state.nameOptions
|
| 183 |
+
if (!this.assetOptions.length) this.assetOptions = state.assetOptions
|
| 184 |
+
if (!this.modelOptions.length) this.modelOptions = state.modelOptions
|
| 185 |
+
if (!this.strategyOptions.length) this.strategyOptions = state.strategyOptions
|
| 186 |
+
|
| 187 |
+
// 初始化 filters(全选)
|
| 188 |
+
if (!this.filters.names.length && state.nameOptions.length) {
|
| 189 |
+
this.filters.names = state.nameOptions.map(o => o.value)
|
| 190 |
+
}
|
| 191 |
+
if (!this.filters.assets.length && state.assetOptions.length) {
|
| 192 |
+
this.filters.assets = state.assetOptions.map(o => o.value)
|
| 193 |
+
}
|
| 194 |
+
if (!this.filters.models.length && state.modelOptions.length) {
|
| 195 |
+
this.filters.models = state.modelOptions.map(o => o.value)
|
| 196 |
+
}
|
| 197 |
+
if (!this.filters.strategies.length && state.strategyOptions.length) {
|
| 198 |
+
this.filters.strategies = state.strategyOptions.map(o => o.value)
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
onUpdatedClick(){
|
| 202 |
+
try {
|
| 203 |
+
this.updatedClickCount = (this.updatedClickCount || 0) + 1
|
| 204 |
+
if (this.updatedClickCount >= 5) {
|
| 205 |
+
this.requestButtonVisible = true
|
| 206 |
+
this.updatedClickCount = 0
|
| 207 |
+
}
|
| 208 |
+
} catch(_) {}
|
| 209 |
+
},
|
| 210 |
+
async recomputeRowForRange(row, start, end){
|
| 211 |
+
try {
|
| 212 |
+
const isCrypto = row.asset === 'BTC' || row.asset === 'ETH'
|
| 213 |
+
const seriesAll = Array.isArray(row.series) ? row.series : []
|
| 214 |
+
const inRange = seriesAll.filter(p => {
|
| 215 |
+
const d = new Date(p.date)
|
| 216 |
+
return d >= start && d <= end
|
| 217 |
+
})
|
| 218 |
+
if (!inRange.length) return null
|
| 219 |
+
// Get correct strategy config - row.strategy is the ID, we need the strategy type
|
| 220 |
+
const strategyCfg = (STRATEGIES || []).find(s => s.id === row.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 }
|
| 221 |
+
const st = computeStrategyEquity(inRange, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode)
|
| 222 |
+
const stNoFee = computeStrategyEquity(inRange, 100000, 0, strategyCfg.strategy, strategyCfg.tradingMode)
|
| 223 |
+
const metrics = calculateMetricsFromSeries(st, isCrypto ? 'crypto' : 'stock')
|
| 224 |
+
const metricsNoFee = calculateMetricsFromSeries(stNoFee, isCrypto ? 'crypto' : 'stock')
|
| 225 |
+
const { winRate, trades } = computeWinRate(inRange, strategyCfg.strategy, strategyCfg.tradingMode)
|
| 226 |
+
const bhSeries = computeBuyHoldEquity(inRange, 100000)
|
| 227 |
+
const buy_hold = bhSeries.length ? (bhSeries[bhSeries.length - 1] - bhSeries[0]) / bhSeries[0] : 0
|
| 228 |
+
const dates = inRange.map(s => s.date).filter(Boolean).sort()
|
| 229 |
+
const start_date = dates[0] || row.start_date
|
| 230 |
+
const end_date = dates[dates.length - 1] || row.end_date
|
| 231 |
+
let trading_days = 0
|
| 232 |
+
let closed_days = 0
|
| 233 |
+
if (isCrypto) {
|
| 234 |
+
trading_days = Math.max(0, Math.floor((new Date(end_date) - new Date(start_date)) / 86400000) + 1)
|
| 235 |
+
closed_days = 0
|
| 236 |
+
} else {
|
| 237 |
+
// accurate counts via calendar utils
|
| 238 |
+
trading_days = await countTradingDaysBetweenForAsset(row.asset, start_date, end_date)
|
| 239 |
+
closed_days = await countNonTradingDaysBetweenForAsset(row.asset, start_date, end_date)
|
| 240 |
+
}
|
| 241 |
+
return {
|
| 242 |
+
...row,
|
| 243 |
+
decision_ids: inRange.map(r => r.id).filter(Boolean),
|
| 244 |
+
balance: st.length ? st[st.length - 1] : 100000,
|
| 245 |
+
ret_with_fees: metrics.total_return / 100,
|
| 246 |
+
ret_no_fees: metricsNoFee.total_return / 100,
|
| 247 |
+
buy_hold,
|
| 248 |
+
vs_bh_with_fees: (metrics.total_return / 100) - buy_hold,
|
| 249 |
+
sharpe: metrics.sharpe_ratio,
|
| 250 |
+
win_rate: winRate,
|
| 251 |
+
trades,
|
| 252 |
+
start_date,
|
| 253 |
+
end_date,
|
| 254 |
+
trading_days,
|
| 255 |
+
closed_days,
|
| 256 |
+
closed_date: closed_days
|
| 257 |
+
}
|
| 258 |
+
} catch(_) { return row }
|
| 259 |
+
},
|
| 260 |
+
async recomputeAllForRange(start, end){
|
| 261 |
+
try {
|
| 262 |
+
const tasks = (this.tableRows || []).map(r => this.recomputeRowForRange(r, start, end))
|
| 263 |
+
const res = await Promise.all(tasks)
|
| 264 |
+
this.dateFilteredRows = (res || []).filter(Boolean)
|
| 265 |
+
} catch(_) {
|
| 266 |
+
this.dateFilteredRows = []
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
enterSelectMode(){ this.selectMode = true; this.selectedRows = [] },
|
| 270 |
+
exitSelectMode(){ this.selectMode = false; this.selectedRows = []; this.compareVisible = false },
|
| 271 |
+
openCompareDialog(){ this.compareVisible = true },
|
| 272 |
+
goCompare(){
|
| 273 |
+
try {
|
| 274 |
+
const payload = (this.selectedRows || []).map(r => ({
|
| 275 |
+
agent_name: r.agent_name,
|
| 276 |
+
asset: r.asset,
|
| 277 |
+
model: r.model,
|
| 278 |
+
strategy: r.strategy,
|
| 279 |
+
decision_ids: Array.isArray(r.decision_ids) ? r.decision_ids : []
|
| 280 |
+
}))
|
| 281 |
+
console.log('[Compare] selectedRows:', this.selectedRows)
|
| 282 |
+
console.log('[Compare] payload size:', payload.length, payload)
|
| 283 |
+
sessionStorage.setItem('compareRows', JSON.stringify(payload))
|
| 284 |
+
} catch(_) {}
|
| 285 |
+
// keep old navigation path available, but we now prefer dialog view
|
| 286 |
+
// this.$router.push('/equity-comparison')
|
| 287 |
+
},
|
| 288 |
+
requestAsset(){
|
| 289 |
+
console.log('[Request Asset] asset:', this.asset)
|
| 290 |
+
const svc = import.meta.env.VITE_EMAILJS_SERVICE_ID
|
| 291 |
+
const tpl = import.meta.env.VITE_EMAILJS_TEMPLATE_ID
|
| 292 |
+
if (!this.asset || !svc || !tpl) { this.requestAssetsVisible = false; this.asset=''; return }
|
| 293 |
+
try {
|
| 294 |
+
emailjs.send(svc, tpl, { title: String(this.asset), message: String(this.asset) })
|
| 295 |
+
} catch(_) {}
|
| 296 |
+
this.asset = ''
|
| 297 |
+
this.requestAssetsVisible = false
|
| 298 |
+
},
|
| 299 |
+
async forceRefresh(){
|
| 300 |
+
// reset compare state on refresh
|
| 301 |
+
try { this.exitSelectMode() } catch(_) {}
|
| 302 |
+
// 使用 dataService 强制刷新
|
| 303 |
+
await dataService.forceRefresh()
|
| 304 |
+
}
|
| 305 |
+
},
|
| 306 |
+
mounted() {
|
| 307 |
+
// 订阅 dataService 状态变化
|
| 308 |
+
this.unsubscribe = dataService.subscribe((state) => {
|
| 309 |
+
this.syncFromDataService(state)
|
| 310 |
+
})
|
| 311 |
+
|
| 312 |
+
// 立即同步当前状态
|
| 313 |
+
this.syncFromDataService(dataService.getState())
|
| 314 |
+
|
| 315 |
+
// 如果数据还没加载,触发加载
|
| 316 |
+
if (!dataService.loaded && !dataService.loading) {
|
| 317 |
+
dataService.load()
|
| 318 |
+
}
|
| 319 |
+
},
|
| 320 |
+
beforeUnmount() {
|
| 321 |
+
// 取消订阅
|
| 322 |
+
if (this.unsubscribe) {
|
| 323 |
+
this.unsubscribe()
|
| 324 |
+
this.unsubscribe = null
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
</script>
|
| 329 |
+
|
| 330 |
+
<style scoped>
|
| 331 |
+
.page-wrapper {
|
| 332 |
+
max-width: 1600px;
|
| 333 |
+
margin: 0 auto;
|
| 334 |
+
padding: 0 1rem 1rem 1rem;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.title-container {
|
| 338 |
+
text-align: center;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.main-title {
|
| 342 |
+
font-size: 2rem;
|
| 343 |
+
letter-spacing: -0.02em;
|
| 344 |
+
font-weight: 800;
|
| 345 |
+
color: #1f1f33;
|
| 346 |
+
}
|
| 347 |
+
.loading-overlay {
|
| 348 |
+
position: fixed;
|
| 349 |
+
inset: 0;
|
| 350 |
+
background: rgba(255, 255, 255, 0.85);
|
| 351 |
+
z-index: 1000;
|
| 352 |
+
display: flex;
|
| 353 |
+
align-items: center;
|
| 354 |
+
justify-content: center;
|
| 355 |
+
}
|
| 356 |
+
.loading-box { text-align: center; }
|
| 357 |
+
|
| 358 |
+
.card--with-divider :deep(.p-card-title) {
|
| 359 |
+
border-bottom: 1px solid var(--surface-200);
|
| 360 |
+
padding-bottom: 0.75rem;
|
| 361 |
+
margin-bottom: 0.75rem;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
/* equal-height for side-by-side cards */
|
| 365 |
+
.col-panel { display: flex; }
|
| 366 |
+
.card-full { width: 100%; display: flex; flex-direction: column; }
|
| 367 |
+
.card-full :deep(.p-card-body) { display: flex; flex-direction: column; height: 100%; }
|
| 368 |
+
.card-full :deep(.p-card-content) { flex: 1; display: flex; flex-direction: column; }
|
| 369 |
+
|
| 370 |
+
/* compact spacing for higher information density */
|
| 371 |
+
.compact-card :deep(.p-card-body) { padding: 0.75rem; }
|
| 372 |
+
.compact-card :deep(.p-card-content) { padding-top: 0; padding-bottom: 0; overflow-y: auto; }
|
| 373 |
+
.compact-card :deep(.p-card-title) { margin-bottom: 0.5rem; }
|
| 374 |
+
|
| 375 |
+
/* datatable compact paddings */
|
| 376 |
+
:deep(.p-datatable .p-datatable-header) { padding: 0.5rem 0.75rem; }
|
| 377 |
+
:deep(.p-datatable .p-datatable-footer) { padding: 0.5rem 0.75rem; }
|
| 378 |
+
:deep(.p-datatable .p-datatable-thead > tr > th) { padding: 0.5rem 0.5rem; font-size: 0.9rem; }
|
| 379 |
+
:deep(.p-datatable .p-datatable-tbody > tr > td) { padding: 0.4rem 0.5rem; font-size: 0.9rem; line-height: 1.2; }
|
| 380 |
+
|
| 381 |
+
/* multiselect and inputs in filter card */
|
| 382 |
+
.compact-card :deep(.p-inputtext),
|
| 383 |
+
.compact-card :deep(.p-multiselect) { font-size: 0.9rem; }
|
| 384 |
+
.compact-card :deep(.p-inputtext) { padding: 0.35rem 0.5rem; }
|
| 385 |
+
.compact-card :deep(.p-multiselect .p-multiselect-label) { padding: 0.35rem 0.5rem; }
|
| 386 |
+
.compact-card :deep(.p-multiselect .p-multiselect-trigger) { width: 1.5rem; }
|
| 387 |
+
|
| 388 |
+
/* dialog compact header/content on this page */
|
| 389 |
+
:deep(.p-dialog .p-dialog-header) { padding: 0.6rem 0.9rem; }
|
| 390 |
+
:deep(.p-dialog .p-dialog-content) { padding: 0.6rem 0.9rem; }
|
| 391 |
+
|
| 392 |
+
/* empty state position: lower center (about 65% height) */
|
| 393 |
+
.empty-offset { flex: 1; min-height: 280px; position: relative; }
|
| 394 |
+
.empty-offset > span {
|
| 395 |
+
position: absolute;
|
| 396 |
+
left: 50%;
|
| 397 |
+
top: 50%;
|
| 398 |
+
transform: translate(-50%, -50%);
|
| 399 |
+
text-align: center;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
/* Content cards with adaptive height */
|
| 403 |
+
.content-card {
|
| 404 |
+
height: calc(100vh - 280px);
|
| 405 |
+
min-height: 500px;
|
| 406 |
+
max-height: 850px;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
/* Responsive design for mobile */
|
| 410 |
+
.mobile-filter-btn-container {
|
| 411 |
+
display: none;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
@media (max-width: 768px) {
|
| 415 |
+
/* Show mobile filter button */
|
| 416 |
+
.mobile-filter-btn-container {
|
| 417 |
+
display: flex;
|
| 418 |
+
justify-content: flex-start;
|
| 419 |
+
margin-bottom: 1rem;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/* Hide desktop filter panel */
|
| 423 |
+
.desktop-filter-panel {
|
| 424 |
+
display: none !important;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
/* Adjust main content to full width on mobile */
|
| 428 |
+
.col-panel {
|
| 429 |
+
flex: 1 !important;
|
| 430 |
+
min-width: 0 !important;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/* Adjust card height for mobile */
|
| 434 |
+
.content-card {
|
| 435 |
+
height: calc(100vh - 350px);
|
| 436 |
+
min-height: 400px;
|
| 437 |
+
max-height: none;
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* Landscape mode optimization */
|
| 442 |
+
@media (max-height: 600px) and (orientation: landscape) {
|
| 443 |
+
.content-card {
|
| 444 |
+
height: calc(100vh - 180px);
|
| 445 |
+
min-height: 300px;
|
| 446 |
+
max-height: none;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.page-wrapper {
|
| 450 |
+
padding: 0 1rem 0.5rem 1rem;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.title-container {
|
| 454 |
+
margin-bottom: 0.5rem;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.main-title {
|
| 458 |
+
font-size: 1.5rem;
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* Drawer custom styles */
|
| 463 |
+
:deep(.filter-drawer) {
|
| 464 |
+
width: 320px;
|
| 465 |
+
max-width: 85vw;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
:deep(.filter-drawer .p-drawer-content) {
|
| 469 |
+
padding: 1rem;
|
| 470 |
+
}
|
| 471 |
+
</style>
|
| 472 |
+
|
src/views/LiveView.vue
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div>
|
| 3 |
+
<h1>Live</h1>
|
| 4 |
+
</div>
|
| 5 |
+
</template>
|
| 6 |
+
|
| 7 |
+
<script>
|
| 8 |
+
export default {
|
| 9 |
+
name: 'LiveView'
|
| 10 |
+
}
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<style scoped>
|
| 14 |
+
.live-container {
|
| 15 |
+
width: 100%;
|
| 16 |
+
height: 100%;
|
| 17 |
+
}
|
| 18 |
+
</style>
|
tpl.env
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
VITE_SUPABASE_URL=URL
|
| 2 |
+
VITE_SUPABASE_ANON_KEY=KEY_WITH_PLAIN_PERMISSION
|
| 3 |
+
VITE_SUPABASE_SERVICE_ROLE_KEY=KEY_WITH_SERVICE_ROLE_PERMISSION
|
| 4 |
+
VITE_MAX_ROWS=1000
|
| 5 |
+
VITE_EMAILJS_PUBLIC_KEY=YOUR_EMAILJS_PUBLIC_KEY
|
| 6 |
+
VITE_EMAILJS_SERVICE_ID=YOUR_EMAILJS_SERVICE_ID
|
| 7 |
+
VITE_EMAILJS_TEMPLATE_ID=YOUR_EMAILJS_TEMPLATE_ID
|
vite.config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import vue from '@vitejs/plugin-vue'
|
| 3 |
+
|
| 4 |
+
// NOTE: keep it minimal; env vars will be injected as import.meta.env.VITE_*
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [vue()],
|
| 7 |
+
server: {
|
| 8 |
+
host: true,
|
| 9 |
+
port: 5173
|
| 10 |
+
}
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
|