Jimin Huang commited on
Commit
5fa7a59
·
1 Parent(s): d404e4f

add: Feature

Browse files
Files changed (43) hide show
  1. Dockerfile +26 -0
  2. README.md +33 -22
  3. docker-compose.yml +37 -0
  4. index.html +9 -8
  5. package-lock.json +0 -0
  6. package.json +25 -26
  7. src/App.vue +11 -77
  8. src/assets/images/assets_images/AAPL.png +3 -0
  9. src/assets/images/assets_images/BMRN.png +3 -0
  10. src/assets/images/assets_images/BTC.png +3 -0
  11. src/assets/images/assets_images/ETH.png +3 -0
  12. src/assets/images/assets_images/MRNA.png +3 -0
  13. src/assets/images/assets_images/MSFT.png +3 -0
  14. src/assets/images/assets_images/TSLA.png +3 -0
  15. src/assets/images/companies_images/deepkin_logo.png +3 -0
  16. src/assets/images/companies_images/logofinai.png +3 -0
  17. src/assets/images/companies_images/nactemlogo.png +3 -0
  18. src/assets/images/companies_images/paalai_logo.png +3 -0
  19. src/components/AgentFilters.vue +322 -0
  20. src/components/AgentTable.vue +156 -0
  21. src/components/AssetsFilter.vue +91 -0
  22. src/components/CompareChart.vue +211 -0
  23. src/components/ExpansionContent.vue +224 -0
  24. src/components/Footer.vue +98 -0
  25. src/components/Header.vue +120 -0
  26. src/components/PerformanceChart.vue +203 -0
  27. src/lib/chartColors.js +214 -0
  28. src/lib/dataCache.js +16 -0
  29. src/lib/dataService.js +399 -0
  30. src/lib/idb.js +137 -0
  31. src/lib/marketCalendar.js +195 -0
  32. src/lib/perf.js +177 -0
  33. src/lib/strategies.js +11 -0
  34. src/lib/supabase.js +32 -0
  35. src/main.js +89 -0
  36. src/pages/EquityComparison.vue +350 -0
  37. src/pages/Main.vue +27 -0
  38. src/router/index.js +42 -0
  39. src/views/AddAssetView.vue +11 -0
  40. src/views/LeadboardView.vue +472 -0
  41. src/views/LiveView.vue +18 -0
  42. tpl.env +7 -0
  43. 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 Agents (Gradio)
3
  emoji: 📈
4
  colorFrom: indigo
5
- colorTo: yellow
6
- sdk: gradio
 
7
  pinned: false
 
8
  license: apache-2.0
9
  ---
10
 
11
- A Gradio app that visualizes paper-trading agent decisions from Supabase, computes equity curves & metrics, and compares against a buy-and-hold baseline.
12
 
13
- ## Configure
14
 
15
- Set the following **Secrets** in your Space (Settings → Variables and secrets):
16
 
17
- - `SUPABASE_URL`
18
- - `SUPABASE_ANON_KEY`
 
 
 
 
19
 
20
- Optionally, set `DEFAULT_MAX_ROWS` (default 10000).
21
 
22
- ## Schema (expected)
 
 
 
23
 
24
- Table: `trading_decisions`
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
- ## Notes
 
 
 
 
35
 
36
- - No service-role keys are used; ensure RLS policies permit read access for your Space domain.
37
- - Holiday-aware calendars can be added; currently the app treats all days as trading days and sorts by date.
 
 
 
 
 
 
 
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
- <!DOCTYPE html>
2
- <html lang="">
3
  <head>
4
- <meta charset="UTF-8">
5
- <link rel="icon" href="/favicon.ico">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Vite App</title>
8
  </head>
9
  <body>
10
  <div id="app"></div>
11
- <script type="module" src="/src/main.ts"></script>
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": "vue",
3
- "version": "0.0.0",
4
  "private": true,
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
- "build": "run-p type-check \"build-only {@}\" --",
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
- "pinia": "^3.0.1",
17
- "vue": "^3.5.13",
18
- "vue-router": "^4.5.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  },
20
  "devDependencies": {
21
- "@tsconfig/node22": "^22.0.1",
22
- "@types/node": "^22.14.0",
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
- <header>
8
- <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
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
- <style scoped>
24
- header {
25
- line-height: 1.5;
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
- nav a {
50
- display: inline-block;
51
- padding: 0 1rem;
52
- border-left: 1px solid var(--color-border);
53
  }
54
-
55
- nav a:first-of-type {
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

  • SHA256: 97730924512d3f310eed15e4f8f76412cdf48def0ee0ead4018a96879842da62
  • Pointer size: 130 Bytes
  • Size of remote file: 20.7 kB
src/assets/images/assets_images/BMRN.png ADDED

Git LFS Details

  • SHA256: 3b91831a041fc15522075d0314852cdc53e6ddb5444aa66f43cae03d9b520f46
  • Pointer size: 130 Bytes
  • Size of remote file: 19.1 kB
src/assets/images/assets_images/BTC.png ADDED

Git LFS Details

  • SHA256: 12898c3f8d7ccc1d60553496df18621036e9aa4ee9b98dcc690dc2245d368fc2
  • Pointer size: 130 Bytes
  • Size of remote file: 48.5 kB
src/assets/images/assets_images/ETH.png ADDED

Git LFS Details

  • SHA256: 9ca2060f10e6130d579366afd9809833f0853056778774caadeb4e6cd1e5f925
  • Pointer size: 130 Bytes
  • Size of remote file: 27 kB
src/assets/images/assets_images/MRNA.png ADDED

Git LFS Details

  • SHA256: 14e72c1d6a79b9598d33d4a802d96d60cdce3a8cfb35cb5130db3d4fb26b96b0
  • Pointer size: 130 Bytes
  • Size of remote file: 11.9 kB
src/assets/images/assets_images/MSFT.png ADDED

Git LFS Details

  • SHA256: d6dd2698aca5a0109d2561b4ec435511f4d92bec123cfb0eeb98f37acfd8afbc
  • Pointer size: 130 Bytes
  • Size of remote file: 86.1 kB
src/assets/images/assets_images/TSLA.png ADDED

Git LFS Details

  • SHA256: 96ba054c8fc2227da4c21b42a004f3fc07ae557b8e4e6178e5468e21502c5c85
  • Pointer size: 130 Bytes
  • Size of remote file: 16.5 kB
src/assets/images/companies_images/deepkin_logo.png ADDED

Git LFS Details

  • SHA256: 52ea8ba7e88208b5c9794cd99f3152a0e5ac1d23f272cf84f218febbf63f1289
  • Pointer size: 131 Bytes
  • Size of remote file: 554 kB
src/assets/images/companies_images/logofinai.png ADDED

Git LFS Details

  • SHA256: bb7323b9c3a034abb4869db01a6f2945b25239a106ee09a22a88904531cfbb6d
  • Pointer size: 130 Bytes
  • Size of remote file: 64.9 kB
src/assets/images/companies_images/nactemlogo.png ADDED

Git LFS Details

  • SHA256: aefef87e040ad90c02b86318a4476f60e60d28912b7fe7c8c505414d1dc30ed5
  • Pointer size: 131 Bytes
  • Size of remote file: 173 kB
src/assets/images/companies_images/paalai_logo.png ADDED

Git LFS Details

  • SHA256: 85e25b40b8965798e87c8ddcc0366f12671d717d561386dfe255124d357c7917
  • Pointer size: 129 Bytes
  • Size of remote file: 6.92 kB
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
+