onesecondtrader 0.40.0__py3-none-any.whl → 0.43.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onesecondtrader/__init__.py +4 -0
- onesecondtrader/connectors/datafeeds/simulated.py +63 -17
- onesecondtrader/core/datafeeds/base.py +3 -0
- onesecondtrader/core/events/requests.py +2 -0
- onesecondtrader/core/models/__init__.py +4 -1
- onesecondtrader/core/models/orders.py +12 -0
- onesecondtrader/core/models/params.py +21 -0
- onesecondtrader/core/models/records.py +2 -0
- onesecondtrader/core/strategies/base.py +17 -4
- onesecondtrader/core/strategies/examples.py +19 -7
- onesecondtrader/dashboard/__init__.py +3 -0
- onesecondtrader/dashboard/app.py +2972 -0
- onesecondtrader/dashboard/registry.py +100 -0
- onesecondtrader/orchestrator/__init__.py +7 -0
- onesecondtrader/orchestrator/orchestrator.py +105 -0
- onesecondtrader/orchestrator/recorder.py +199 -0
- onesecondtrader/orchestrator/schema.sql +212 -0
- onesecondtrader/secmaster/schema.sql +48 -0
- onesecondtrader/secmaster/utils.py +90 -0
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.43.0.dist-info}/METADATA +3 -1
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.43.0.dist-info}/RECORD +23 -16
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.43.0.dist-info}/WHEEL +0 -0
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.43.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,2972 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import shutil
|
|
8
|
+
import sqlite3
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, BackgroundTasks
|
|
11
|
+
from fastapi.responses import HTMLResponse, FileResponse
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
import matplotlib
|
|
15
|
+
|
|
16
|
+
matplotlib.use("Agg")
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
import matplotlib.dates as mdates
|
|
19
|
+
from matplotlib.patches import Rectangle
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from onesecondtrader.orchestrator import Orchestrator
|
|
23
|
+
from onesecondtrader.connectors.brokers import SimulatedBroker
|
|
24
|
+
from onesecondtrader.connectors.datafeeds import SimulatedDatafeed
|
|
25
|
+
from onesecondtrader.core.models.orders import ActionType
|
|
26
|
+
from . import registry
|
|
27
|
+
|
|
28
|
+
CHARTS_DIR = pathlib.Path(os.environ.get("CHARTS_DIR", "charts"))
|
|
29
|
+
|
|
30
|
+
app = FastAPI(title="OneSecondTrader Dashboard")
|
|
31
|
+
|
|
32
|
+
_running_jobs: dict[str, str] = {}
|
|
33
|
+
|
|
34
|
+
RTYPE_TO_BAR_PERIOD = {32: "SECOND", 33: "MINUTE", 34: "HOUR", 35: "DAY"}
|
|
35
|
+
BAR_PERIOD_TO_RTYPE = {"SECOND": 32, "MINUTE": 33, "HOUR": 34, "DAY": 35}
|
|
36
|
+
BAR_PERIOD_ENUM_TO_NAME = {1: "SECOND", 2: "MINUTE", 3: "HOUR", 4: "DAY"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_bar_period(value) -> str | None:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
if value in BAR_PERIOD_TO_RTYPE:
|
|
44
|
+
return value
|
|
45
|
+
try:
|
|
46
|
+
value = int(value)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, int):
|
|
50
|
+
return BAR_PERIOD_ENUM_TO_NAME.get(value, str(value))
|
|
51
|
+
return str(value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_secmaster_path() -> str:
|
|
55
|
+
return os.environ.get("SECMASTER_DB_PATH", "secmaster.db")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_available_symbols(bar_period: str | None = None) -> list[str]:
|
|
59
|
+
db_path = _get_secmaster_path()
|
|
60
|
+
if not os.path.exists(db_path):
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
conn = sqlite3.connect(db_path)
|
|
64
|
+
cursor = conn.cursor()
|
|
65
|
+
if bar_period and bar_period in BAR_PERIOD_TO_RTYPE:
|
|
66
|
+
rtype = BAR_PERIOD_TO_RTYPE[bar_period]
|
|
67
|
+
cursor.execute(
|
|
68
|
+
"SELECT DISTINCT symbol FROM symbol_coverage WHERE rtype = ? ORDER BY symbol",
|
|
69
|
+
(rtype,),
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
cursor.execute("SELECT DISTINCT symbol FROM symbol_coverage ORDER BY symbol")
|
|
73
|
+
symbols = [row[0] for row in cursor.fetchall()]
|
|
74
|
+
conn.close()
|
|
75
|
+
return symbols
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_symbols_with_coverage(bar_period: str) -> list[dict]:
|
|
79
|
+
db_path = _get_secmaster_path()
|
|
80
|
+
if not os.path.exists(db_path) or bar_period not in BAR_PERIOD_TO_RTYPE:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
rtype = BAR_PERIOD_TO_RTYPE[bar_period]
|
|
84
|
+
conn = sqlite3.connect(db_path)
|
|
85
|
+
cursor = conn.cursor()
|
|
86
|
+
cursor.execute(
|
|
87
|
+
"SELECT symbol, min_ts, max_ts FROM symbol_coverage WHERE rtype = ? ORDER BY symbol",
|
|
88
|
+
(rtype,),
|
|
89
|
+
)
|
|
90
|
+
symbols = [
|
|
91
|
+
{"symbol": row[0], "min_ts": row[1], "max_ts": row[2]}
|
|
92
|
+
for row in cursor.fetchall()
|
|
93
|
+
]
|
|
94
|
+
conn.close()
|
|
95
|
+
return symbols
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_available_bar_periods() -> list[str]:
|
|
99
|
+
db_path = _get_secmaster_path()
|
|
100
|
+
if not os.path.exists(db_path):
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
conn = sqlite3.connect(db_path)
|
|
104
|
+
cursor = conn.cursor()
|
|
105
|
+
cursor.execute("SELECT DISTINCT rtype FROM symbol_coverage ORDER BY rtype")
|
|
106
|
+
periods = [
|
|
107
|
+
RTYPE_TO_BAR_PERIOD[row[0]]
|
|
108
|
+
for row in cursor.fetchall()
|
|
109
|
+
if row[0] in RTYPE_TO_BAR_PERIOD
|
|
110
|
+
]
|
|
111
|
+
conn.close()
|
|
112
|
+
return periods
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
BASE_STYLE = """
|
|
116
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
117
|
+
body {
|
|
118
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
119
|
+
background: #0d1117;
|
|
120
|
+
color: #e6edf3;
|
|
121
|
+
min-height: 100vh;
|
|
122
|
+
display: flex;
|
|
123
|
+
}
|
|
124
|
+
.sidebar {
|
|
125
|
+
width: 220px;
|
|
126
|
+
background: #161b22;
|
|
127
|
+
border-right: 1px solid #30363d;
|
|
128
|
+
min-height: 100vh;
|
|
129
|
+
position: fixed;
|
|
130
|
+
left: 0;
|
|
131
|
+
top: 0;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
}
|
|
135
|
+
.sidebar-header {
|
|
136
|
+
padding: 20px 16px;
|
|
137
|
+
border-bottom: 1px solid #30363d;
|
|
138
|
+
}
|
|
139
|
+
.sidebar-header h1 {
|
|
140
|
+
font-size: 16px;
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
color: #e6edf3;
|
|
143
|
+
}
|
|
144
|
+
.sidebar-nav {
|
|
145
|
+
padding: 12px 8px;
|
|
146
|
+
flex: 1;
|
|
147
|
+
}
|
|
148
|
+
.sidebar-nav a {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 10px;
|
|
152
|
+
padding: 10px 12px;
|
|
153
|
+
color: #8b949e;
|
|
154
|
+
text-decoration: none;
|
|
155
|
+
font-size: 14px;
|
|
156
|
+
border-radius: 6px;
|
|
157
|
+
margin-bottom: 2px;
|
|
158
|
+
}
|
|
159
|
+
.sidebar-nav a:hover {
|
|
160
|
+
background: #21262d;
|
|
161
|
+
color: #e6edf3;
|
|
162
|
+
}
|
|
163
|
+
.sidebar-nav a.active {
|
|
164
|
+
background: #21262d;
|
|
165
|
+
color: #e6edf3;
|
|
166
|
+
}
|
|
167
|
+
.sidebar-nav svg {
|
|
168
|
+
width: 16px;
|
|
169
|
+
height: 16px;
|
|
170
|
+
flex-shrink: 0;
|
|
171
|
+
}
|
|
172
|
+
.nav-group {
|
|
173
|
+
margin-bottom: 2px;
|
|
174
|
+
}
|
|
175
|
+
.nav-group-toggle {
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
gap: 10px;
|
|
179
|
+
padding: 10px 12px;
|
|
180
|
+
color: #8b949e;
|
|
181
|
+
text-decoration: none;
|
|
182
|
+
font-size: 14px;
|
|
183
|
+
border-radius: 6px;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
width: 100%;
|
|
186
|
+
background: none;
|
|
187
|
+
border: none;
|
|
188
|
+
}
|
|
189
|
+
.nav-group-toggle:hover {
|
|
190
|
+
background: #21262d;
|
|
191
|
+
color: #e6edf3;
|
|
192
|
+
}
|
|
193
|
+
.nav-group-toggle.active {
|
|
194
|
+
background: #21262d;
|
|
195
|
+
color: #e6edf3;
|
|
196
|
+
}
|
|
197
|
+
.nav-group-toggle .chevron {
|
|
198
|
+
margin-left: auto;
|
|
199
|
+
transition: transform 0.2s;
|
|
200
|
+
}
|
|
201
|
+
.nav-group.open .chevron {
|
|
202
|
+
transform: rotate(90deg);
|
|
203
|
+
}
|
|
204
|
+
.nav-group-items {
|
|
205
|
+
display: none;
|
|
206
|
+
padding-left: 26px;
|
|
207
|
+
}
|
|
208
|
+
.nav-group.open .nav-group-items {
|
|
209
|
+
display: block;
|
|
210
|
+
}
|
|
211
|
+
.nav-group-items a {
|
|
212
|
+
padding: 8px 12px;
|
|
213
|
+
font-size: 13px;
|
|
214
|
+
}
|
|
215
|
+
.main-content {
|
|
216
|
+
margin-left: 220px;
|
|
217
|
+
flex: 1;
|
|
218
|
+
min-height: 100vh;
|
|
219
|
+
}
|
|
220
|
+
.container {
|
|
221
|
+
max-width: 1200px;
|
|
222
|
+
margin: 0 auto;
|
|
223
|
+
padding: 32px 24px;
|
|
224
|
+
}
|
|
225
|
+
.card {
|
|
226
|
+
background: #161b22;
|
|
227
|
+
border: 1px solid #30363d;
|
|
228
|
+
border-radius: 8px;
|
|
229
|
+
padding: 24px;
|
|
230
|
+
margin-bottom: 16px;
|
|
231
|
+
}
|
|
232
|
+
.card h2 {
|
|
233
|
+
font-size: 16px;
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
margin-bottom: 16px;
|
|
236
|
+
color: #e6edf3;
|
|
237
|
+
}
|
|
238
|
+
.empty-state {
|
|
239
|
+
text-align: center;
|
|
240
|
+
padding: 48px;
|
|
241
|
+
color: #8b949e;
|
|
242
|
+
}
|
|
243
|
+
.empty-state p { margin-top: 8px; font-size: 14px; }
|
|
244
|
+
.badge {
|
|
245
|
+
display: inline-block;
|
|
246
|
+
padding: 4px 10px;
|
|
247
|
+
border-radius: 12px;
|
|
248
|
+
font-size: 12px;
|
|
249
|
+
font-weight: 500;
|
|
250
|
+
}
|
|
251
|
+
.badge-green { background: #238636; color: #fff; }
|
|
252
|
+
.badge-yellow { background: #9e6a03; color: #fff; }
|
|
253
|
+
.badge-red { background: #da3633; color: #fff; }
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
SIDEBAR_HTML = """
|
|
258
|
+
<aside class="sidebar">
|
|
259
|
+
<div class="sidebar-header">
|
|
260
|
+
<h1>OneSecondTrader</h1>
|
|
261
|
+
</div>
|
|
262
|
+
<nav class="sidebar-nav">
|
|
263
|
+
<a href="/" class="{runs_active}">
|
|
264
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
|
265
|
+
Runs
|
|
266
|
+
</a>
|
|
267
|
+
<a href="/backtest" class="{backtest_active}">
|
|
268
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
|
269
|
+
Backtest
|
|
270
|
+
</a>
|
|
271
|
+
<a href="/secmaster" class="{secmaster_active}">
|
|
272
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>
|
|
273
|
+
Securities Master
|
|
274
|
+
</a>
|
|
275
|
+
<div class="nav-group {pipeline_open}">
|
|
276
|
+
<button class="nav-group-toggle {pipeline_active}" onclick="this.parentElement.classList.toggle('open')">
|
|
277
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg>
|
|
278
|
+
Dev Pipeline
|
|
279
|
+
<svg class="chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24" width="12" height="12"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
280
|
+
</button>
|
|
281
|
+
<div class="nav-group-items">
|
|
282
|
+
<a href="/pipeline/signal-validation" class="{pipeline_sub1_active}">Signal Validation</a>
|
|
283
|
+
<a href="/pipeline/sub2" class="{pipeline_sub2_active}">Sub Tab 2</a>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</nav>
|
|
287
|
+
</aside>
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
RUNS_STYLE = """
|
|
292
|
+
.runs-table { width: 100%; border-collapse: collapse; }
|
|
293
|
+
.runs-table th, .runs-table td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
|
294
|
+
.runs-table th { font-size: 12px; color: #8b949e; text-transform: uppercase; font-weight: 500; }
|
|
295
|
+
.runs-table tbody tr { cursor: pointer; }
|
|
296
|
+
.runs-table tbody tr:hover { background: #21262d; }
|
|
297
|
+
.runs-table .symbols { font-family: monospace; font-size: 12px; color: #8b949e; }
|
|
298
|
+
.runs-table .date-range { font-size: 12px; color: #8b949e; }
|
|
299
|
+
.runs-table .delete-btn { background: none; border: none; cursor: pointer; padding: 4px 8px; color: #8b949e; font-size: 16px; border-radius: 4px; }
|
|
300
|
+
.runs-table .delete-btn:hover { background: #f8514926; color: #f85149; }
|
|
301
|
+
.runs-table .actions { text-align: center; width: 50px; }
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
RUNS_SCRIPT = """
|
|
305
|
+
async function deleteRun(runId, event) {
|
|
306
|
+
event.stopPropagation();
|
|
307
|
+
if (!confirm('Delete this run? This cannot be undone.')) return;
|
|
308
|
+
const res = await fetch(`/api/run/${runId}`, { method: 'DELETE' });
|
|
309
|
+
if (res.ok) loadRuns();
|
|
310
|
+
else alert('Failed to delete run');
|
|
311
|
+
}
|
|
312
|
+
async function loadRuns() {
|
|
313
|
+
const container = document.getElementById('runs-content');
|
|
314
|
+
const res = await fetch('/api/runs');
|
|
315
|
+
const data = await res.json();
|
|
316
|
+
if (!data.runs || data.runs.length === 0) {
|
|
317
|
+
container.innerHTML = `
|
|
318
|
+
<div class="empty-state">
|
|
319
|
+
<p>No runs yet.</p>
|
|
320
|
+
<p>Run a strategy with RunRecorder to see results here.</p>
|
|
321
|
+
</div>
|
|
322
|
+
`;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const rows = data.runs.map(r => {
|
|
326
|
+
const statusClass = r.status === 'completed' ? 'badge-green' : r.status === 'running' ? 'badge-yellow' : 'badge-red';
|
|
327
|
+
const createdAt = r.created_at ? r.created_at.split('T')[0] + ' ' + r.created_at.split('T')[1].split('.')[0] : '-';
|
|
328
|
+
const dateRange = r.first_bar && r.last_bar
|
|
329
|
+
? r.first_bar.split('T')[0] + ' → ' + r.last_bar.split('T')[0]
|
|
330
|
+
: '-';
|
|
331
|
+
return `<tr onclick="window.location='/run/${r.run_id}'">
|
|
332
|
+
<td>${createdAt}</td>
|
|
333
|
+
<td>${r.strategy}</td>
|
|
334
|
+
<td class="symbols">${r.symbols.length}</td>
|
|
335
|
+
<td>${r.bar_period || '-'}</td>
|
|
336
|
+
<td class="date-range">${dateRange}</td>
|
|
337
|
+
<td><span class="badge ${statusClass}">${r.status}</span></td>
|
|
338
|
+
<td class="actions"><button class="delete-btn" onclick="deleteRun('${r.run_id}', event)" title="Delete run">🗑</button></td>
|
|
339
|
+
</tr>`;
|
|
340
|
+
}).join('');
|
|
341
|
+
container.innerHTML = `
|
|
342
|
+
<table class="runs-table">
|
|
343
|
+
<thead><tr><th>Created</th><th>Strategy</th><th>Symbols</th><th>Bar Period</th><th>Date Range</th><th>Status</th><th></th></tr></thead>
|
|
344
|
+
<tbody>${rows}</tbody>
|
|
345
|
+
</table>
|
|
346
|
+
`;
|
|
347
|
+
}
|
|
348
|
+
document.addEventListener('DOMContentLoaded', loadRuns);
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.get("/", response_class=HTMLResponse)
|
|
353
|
+
async def index():
|
|
354
|
+
sidebar = SIDEBAR_HTML.format(
|
|
355
|
+
runs_active="active",
|
|
356
|
+
backtest_active="",
|
|
357
|
+
secmaster_active="",
|
|
358
|
+
pipeline_active="",
|
|
359
|
+
pipeline_open="",
|
|
360
|
+
pipeline_sub1_active="",
|
|
361
|
+
pipeline_sub2_active="",
|
|
362
|
+
)
|
|
363
|
+
return f"""
|
|
364
|
+
<!DOCTYPE html>
|
|
365
|
+
<html>
|
|
366
|
+
<head>
|
|
367
|
+
<title>OneSecondTrader Dashboard</title>
|
|
368
|
+
<style>{BASE_STYLE}{RUNS_STYLE}</style>
|
|
369
|
+
</head>
|
|
370
|
+
<body>
|
|
371
|
+
{sidebar}
|
|
372
|
+
<main class="main-content">
|
|
373
|
+
<div class="container">
|
|
374
|
+
<div class="card">
|
|
375
|
+
<h2>Recent Runs</h2>
|
|
376
|
+
<div id="runs-content">
|
|
377
|
+
<p style="color: #8b949e;">Loading...</p>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</main>
|
|
382
|
+
<script>{RUNS_SCRIPT}</script>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
RUN_DETAIL_STYLE = """
|
|
389
|
+
.run-header { margin-bottom: 24px; }
|
|
390
|
+
.run-header h2 { margin-bottom: 8px; }
|
|
391
|
+
.run-meta { display: flex; gap: 24px; flex-wrap: wrap; color: #8b949e; font-size: 14px; }
|
|
392
|
+
.run-meta span { display: flex; align-items: center; gap: 6px; }
|
|
393
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
394
|
+
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px; }
|
|
395
|
+
.stat-card .label { font-size: 12px; color: #8b949e; margin-bottom: 4px; }
|
|
396
|
+
.stat-card .value { font-size: 24px; font-weight: 600; }
|
|
397
|
+
.back-link { color: #58a6ff; text-decoration: none; font-size: 14px; display: inline-block; margin-bottom: 16px; }
|
|
398
|
+
.back-link:hover { text-decoration: underline; }
|
|
399
|
+
.positions-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
|
|
400
|
+
.positions-table th { text-align: left; padding: 12px 8px; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 500; }
|
|
401
|
+
.positions-table td { padding: 10px 8px; border-bottom: 1px solid #21262d; }
|
|
402
|
+
.positions-table tr:hover { background: #161b22; }
|
|
403
|
+
.pnl-positive { color: #3fb950; }
|
|
404
|
+
.pnl-negative { color: #f85149; }
|
|
405
|
+
.side-long { color: #3fb950; }
|
|
406
|
+
.side-short { color: #f85149; }
|
|
407
|
+
.positions-summary { display: flex; gap: 24px; margin-bottom: 16px; padding: 12px; background: #161b22; border-radius: 6px; }
|
|
408
|
+
.positions-summary .item { display: flex; flex-direction: column; }
|
|
409
|
+
.positions-summary .item-label { font-size: 11px; color: #8b949e; text-transform: uppercase; }
|
|
410
|
+
.positions-summary .item-value { font-size: 18px; font-weight: 600; }
|
|
411
|
+
.positions-filter { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
|
412
|
+
.positions-filter select { background: #21262d; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9; font-size: 14px; min-width: 150px; }
|
|
413
|
+
.positions-filter select:focus { outline: none; border-color: #58a6ff; }
|
|
414
|
+
.positions-filter label { color: #8b949e; font-size: 14px; }
|
|
415
|
+
.position-row { cursor: pointer; }
|
|
416
|
+
.position-row:hover { background: #21262d !important; }
|
|
417
|
+
.chart-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
|
|
418
|
+
.chart-modal-content { background: #161b22; border: 1px solid #30363d; border-radius: 8px; max-width: 95%; max-height: 95%; overflow: auto; }
|
|
419
|
+
.chart-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #30363d; }
|
|
420
|
+
.chart-modal-header h3 { margin: 0; font-size: 16px; }
|
|
421
|
+
.chart-modal-close { background: none; border: none; color: #8b949e; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
|
|
422
|
+
.chart-modal-close:hover { color: #e6edf3; }
|
|
423
|
+
.chart-modal-body { padding: 20px; }
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
RUN_DETAIL_SCRIPT = """
|
|
427
|
+
let allPositions = [];
|
|
428
|
+
function formatTime(ts) {
|
|
429
|
+
if (!ts) return '-';
|
|
430
|
+
return ts.split('T')[0] + ' ' + ts.split('T')[1].split('.')[0];
|
|
431
|
+
}
|
|
432
|
+
function formatPnl(pnl) {
|
|
433
|
+
const cls = pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
434
|
+
const sign = pnl >= 0 ? '+' : '';
|
|
435
|
+
return `<span class="${cls}">${sign}$${pnl.toFixed(2)}</span>`;
|
|
436
|
+
}
|
|
437
|
+
function computeSummary(positions) {
|
|
438
|
+
const totalPnl = positions.reduce((sum, p) => sum + p.pnl, 0);
|
|
439
|
+
const winners = positions.filter(p => p.pnl > 0).length;
|
|
440
|
+
const losers = positions.filter(p => p.pnl < 0).length;
|
|
441
|
+
const winRate = positions.length > 0 ? winners / positions.length : 0;
|
|
442
|
+
return { total_positions: positions.length, total_pnl: totalPnl, winners, losers, win_rate: winRate };
|
|
443
|
+
}
|
|
444
|
+
function renderPositions(positions, selectedSymbol) {
|
|
445
|
+
const filtered = selectedSymbol ? positions.filter(p => p.symbol === selectedSymbol) : positions;
|
|
446
|
+
const s = computeSummary(filtered);
|
|
447
|
+
const winRate = s.win_rate ? (s.win_rate * 100).toFixed(1) + '%' : '-';
|
|
448
|
+
const summaryPnlClass = s.total_pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
449
|
+
document.getElementById('summary-pnl').className = 'item-value ' + summaryPnlClass;
|
|
450
|
+
document.getElementById('summary-pnl').textContent = (s.total_pnl >= 0 ? '+' : '') + '$' + s.total_pnl.toFixed(2);
|
|
451
|
+
document.getElementById('summary-positions').textContent = s.total_positions;
|
|
452
|
+
document.getElementById('summary-winners').textContent = s.winners;
|
|
453
|
+
document.getElementById('summary-losers').textContent = s.losers;
|
|
454
|
+
document.getElementById('summary-winrate').textContent = winRate;
|
|
455
|
+
const tbody = document.getElementById('positions-tbody');
|
|
456
|
+
tbody.innerHTML = filtered.map((p, i) => `
|
|
457
|
+
<tr class="position-row" data-symbol="${p.symbol}" data-position-id="${p.position_id}">
|
|
458
|
+
<td>${i + 1}</td>
|
|
459
|
+
<td>${p.symbol}</td>
|
|
460
|
+
<td class="${p.side === 'LONG' ? 'side-long' : 'side-short'}">${p.side}</td>
|
|
461
|
+
<td>${p.quantity}</td>
|
|
462
|
+
<td>${formatTime(p.entry_time)}</td>
|
|
463
|
+
<td>${formatTime(p.exit_time)}</td>
|
|
464
|
+
<td>$${p.avg_entry_price.toFixed(2)}</td>
|
|
465
|
+
<td>$${p.avg_exit_price.toFixed(2)}</td>
|
|
466
|
+
<td>${formatPnl(p.pnl)}</td>
|
|
467
|
+
</tr>
|
|
468
|
+
`).join('');
|
|
469
|
+
tbody.querySelectorAll('.position-row').forEach(row => {
|
|
470
|
+
row.addEventListener('click', () => {
|
|
471
|
+
const symbol = row.dataset.symbol;
|
|
472
|
+
const positionId = row.dataset.positionId;
|
|
473
|
+
showPositionChart(symbol, positionId);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function onSymbolFilterChange() {
|
|
478
|
+
const sel = document.getElementById('symbol-filter');
|
|
479
|
+
renderPositions(allPositions, sel.value);
|
|
480
|
+
}
|
|
481
|
+
async function loadRunDetail() {
|
|
482
|
+
const runId = window.location.pathname.split('/run/')[1];
|
|
483
|
+
const container = document.getElementById('run-content');
|
|
484
|
+
const [runRes, posRes] = await Promise.all([
|
|
485
|
+
fetch(`/api/run/${runId}`),
|
|
486
|
+
fetch(`/api/run/${runId}/positions`)
|
|
487
|
+
]);
|
|
488
|
+
if (!runRes.ok) {
|
|
489
|
+
container.innerHTML = '<p style="color: #f85149;">Run not found.</p>';
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const r = await runRes.json();
|
|
493
|
+
const posData = await posRes.json();
|
|
494
|
+
allPositions = posData.positions || [];
|
|
495
|
+
const statusClass = r.status === 'completed' ? 'badge-green' : r.status === 'running' ? 'badge-yellow' : 'badge-red';
|
|
496
|
+
const createdAt = formatTime(r.created_at);
|
|
497
|
+
const dateRange = r.first_bar && r.last_bar
|
|
498
|
+
? r.first_bar.split('T')[0] + ' → ' + r.last_bar.split('T')[0]
|
|
499
|
+
: '-';
|
|
500
|
+
const symbols = [...new Set(allPositions.map(p => p.symbol))].sort();
|
|
501
|
+
const s = computeSummary(allPositions);
|
|
502
|
+
const winRate = s.win_rate ? (s.win_rate * 100).toFixed(1) + '%' : '-';
|
|
503
|
+
let positionsHtml = '';
|
|
504
|
+
if (allPositions.length > 0) {
|
|
505
|
+
const summaryPnlClass = s.total_pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
506
|
+
const symbolOptions = symbols.map(sym => `<option value="${sym}">${sym}</option>`).join('');
|
|
507
|
+
positionsHtml = `
|
|
508
|
+
<div class="card" style="margin-top: 24px;">
|
|
509
|
+
<h3>Positions (Round-Trip Trades)</h3>
|
|
510
|
+
<div class="positions-filter">
|
|
511
|
+
<label for="symbol-filter">Filter by Symbol:</label>
|
|
512
|
+
<select id="symbol-filter" onchange="onSymbolFilterChange()">
|
|
513
|
+
<option value="">All Symbols</option>
|
|
514
|
+
${symbolOptions}
|
|
515
|
+
</select>
|
|
516
|
+
</div>
|
|
517
|
+
<div class="positions-summary">
|
|
518
|
+
<div class="item">
|
|
519
|
+
<span class="item-label">Total P&L</span>
|
|
520
|
+
<span id="summary-pnl" class="item-value ${summaryPnlClass}">${s.total_pnl >= 0 ? '+' : ''}$${s.total_pnl.toFixed(2)}</span>
|
|
521
|
+
</div>
|
|
522
|
+
<div class="item">
|
|
523
|
+
<span class="item-label">Positions</span>
|
|
524
|
+
<span id="summary-positions" class="item-value">${s.total_positions}</span>
|
|
525
|
+
</div>
|
|
526
|
+
<div class="item">
|
|
527
|
+
<span class="item-label">Winners</span>
|
|
528
|
+
<span id="summary-winners" class="item-value pnl-positive">${s.winners}</span>
|
|
529
|
+
</div>
|
|
530
|
+
<div class="item">
|
|
531
|
+
<span class="item-label">Losers</span>
|
|
532
|
+
<span id="summary-losers" class="item-value pnl-negative">${s.losers}</span>
|
|
533
|
+
</div>
|
|
534
|
+
<div class="item">
|
|
535
|
+
<span class="item-label">Win Rate</span>
|
|
536
|
+
<span id="summary-winrate" class="item-value">${winRate}</span>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
<table class="positions-table">
|
|
540
|
+
<thead>
|
|
541
|
+
<tr>
|
|
542
|
+
<th>#</th><th>Symbol</th><th>Side</th><th>Qty</th>
|
|
543
|
+
<th>Entry Time</th><th>Exit Time</th><th>Avg Entry</th><th>Avg Exit</th><th>P&L</th>
|
|
544
|
+
</tr>
|
|
545
|
+
</thead>
|
|
546
|
+
<tbody id="positions-tbody"></tbody>
|
|
547
|
+
</table>
|
|
548
|
+
</div>
|
|
549
|
+
`;
|
|
550
|
+
} else {
|
|
551
|
+
positionsHtml = `
|
|
552
|
+
<div class="card" style="margin-top: 24px;">
|
|
553
|
+
<h3>Positions (Round-Trip Trades)</h3>
|
|
554
|
+
<p style="color: #8b949e;">No completed positions.</p>
|
|
555
|
+
</div>
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
container.innerHTML = `
|
|
560
|
+
<a href="/" class="back-link">← Back to Runs</a>
|
|
561
|
+
<div class="run-header">
|
|
562
|
+
<h2>${r.strategy}</h2>
|
|
563
|
+
<div class="run-meta">
|
|
564
|
+
<span><span class="badge ${statusClass}">${r.status}</span></span>
|
|
565
|
+
<span>Created: ${createdAt}</span>
|
|
566
|
+
<span>Mode: ${r.mode}</span>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
<div class="stats-grid">
|
|
570
|
+
<div class="stat-card">
|
|
571
|
+
<div class="label">Symbols</div>
|
|
572
|
+
<div class="value">${r.symbols.length}</div>
|
|
573
|
+
</div>
|
|
574
|
+
<div class="stat-card">
|
|
575
|
+
<div class="label">Bar Period</div>
|
|
576
|
+
<div class="value">${r.bar_period || '-'}</div>
|
|
577
|
+
</div>
|
|
578
|
+
<div class="stat-card">
|
|
579
|
+
<div class="label">Total Bars</div>
|
|
580
|
+
<div class="value">${r.bar_count || 0}</div>
|
|
581
|
+
</div>
|
|
582
|
+
<div class="stat-card">
|
|
583
|
+
<div class="label">Date Range</div>
|
|
584
|
+
<div class="value" style="font-size: 14px;">${dateRange}</div>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
<div class="card">
|
|
588
|
+
<h3>Symbols</h3>
|
|
589
|
+
<p style="color: #8b949e; font-family: monospace;">${r.symbols.join(', ') || 'None'}</p>
|
|
590
|
+
</div>
|
|
591
|
+
${positionsHtml}
|
|
592
|
+
`;
|
|
593
|
+
if (allPositions.length > 0) {
|
|
594
|
+
renderPositions(allPositions, '');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function showPositionChart(symbol, positionId) {
|
|
598
|
+
const modal = document.getElementById('chart-modal');
|
|
599
|
+
const modalTitle = document.getElementById('chart-modal-title');
|
|
600
|
+
const modalBody = document.getElementById('chart-modal-body');
|
|
601
|
+
const runId = window.location.pathname.split('/run/')[1];
|
|
602
|
+
|
|
603
|
+
modalTitle.textContent = `Position Chart - ${symbol} #${positionId}`;
|
|
604
|
+
modalBody.innerHTML = '<div style="text-align: center; padding: 40px;"><p>Generating chart...</p></div>';
|
|
605
|
+
modal.style.display = 'flex';
|
|
606
|
+
|
|
607
|
+
fetch(`/api/run/${runId}/positions/${encodeURIComponent(symbol)}/${positionId}/chart`)
|
|
608
|
+
.then(response => {
|
|
609
|
+
if (!response.ok) {
|
|
610
|
+
return response.json().then(data => { throw new Error(data.error || 'Failed to load chart'); });
|
|
611
|
+
}
|
|
612
|
+
return response.blob();
|
|
613
|
+
})
|
|
614
|
+
.then(blob => {
|
|
615
|
+
const url = URL.createObjectURL(blob);
|
|
616
|
+
modalBody.innerHTML = `<img src="${url}" style="max-width: 100%; max-height: 80vh;">`;
|
|
617
|
+
})
|
|
618
|
+
.catch(error => {
|
|
619
|
+
modalBody.innerHTML = `<div style="color: #f85149; padding: 20px;">${error.message}</div>`;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function closeChartModal() {
|
|
623
|
+
document.getElementById('chart-modal').style.display = 'none';
|
|
624
|
+
}
|
|
625
|
+
document.addEventListener('DOMContentLoaded', loadRunDetail);
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
SIGNAL_VALIDATION_STYLE = """
|
|
630
|
+
.sv-layout { display: flex; gap: 0; height: calc(100vh - 40px); }
|
|
631
|
+
.sv-sidebar { width: 280px; background: #161b22; border-right: 1px solid #30363d; overflow-y: auto; flex-shrink: 0; }
|
|
632
|
+
.sv-main { flex: 1; overflow-y: auto; padding: 20px; }
|
|
633
|
+
.sv-run-selector { padding: 16px; border-bottom: 1px solid #30363d; }
|
|
634
|
+
.sv-run-selector select { width: 100%; background: #21262d; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; color: #c9d1d9; font-size: 14px; }
|
|
635
|
+
.sv-run-selector select:focus { outline: none; border-color: #58a6ff; }
|
|
636
|
+
.sv-run-selector label { display: block; color: #8b949e; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; }
|
|
637
|
+
.sv-action-group { border-bottom: 1px solid #30363d; }
|
|
638
|
+
.sv-action-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; cursor: pointer; color: #c9d1d9; font-weight: 500; }
|
|
639
|
+
.sv-action-header:hover { background: #21262d; }
|
|
640
|
+
.sv-action-header .count { background: #30363d; color: #8b949e; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: normal; }
|
|
641
|
+
.sv-action-header .chevron { transition: transform 0.2s; }
|
|
642
|
+
.sv-action-group.open .sv-action-header .chevron { transform: rotate(90deg); }
|
|
643
|
+
.sv-action-signals { display: none; padding: 8px 16px 12px; }
|
|
644
|
+
.sv-action-group.open .sv-action-signals { display: block; }
|
|
645
|
+
.sv-signal-pill { display: inline-block; background: #21262d; border: 1px solid #30363d; border-radius: 16px; padding: 4px 12px; margin: 4px; font-size: 13px; color: #c9d1d9; cursor: pointer; transition: all 0.15s; }
|
|
646
|
+
.sv-signal-pill:hover { border-color: #58a6ff; background: #1f6feb22; }
|
|
647
|
+
.sv-signal-pill.active { background: #1f6feb; border-color: #1f6feb; color: white; }
|
|
648
|
+
.sv-signal-pill .pill-count { color: #8b949e; font-size: 11px; margin-left: 4px; }
|
|
649
|
+
.sv-signal-pill.active .pill-count { color: rgba(255,255,255,0.7); }
|
|
650
|
+
.sv-content-header { margin-bottom: 20px; }
|
|
651
|
+
.sv-content-header h2 { margin: 0 0 4px; font-size: 20px; }
|
|
652
|
+
.sv-content-header .subtitle { color: #8b949e; font-size: 14px; }
|
|
653
|
+
.sv-empty { color: #8b949e; text-align: center; padding: 60px 20px; }
|
|
654
|
+
.sv-table { width: 100%; border-collapse: collapse; }
|
|
655
|
+
.sv-table th, .sv-table td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
|
656
|
+
.sv-table th { font-size: 12px; color: #8b949e; text-transform: uppercase; font-weight: 500; }
|
|
657
|
+
.sv-table tbody tr { cursor: pointer; }
|
|
658
|
+
.sv-table tbody tr:hover { background: #21262d; }
|
|
659
|
+
.sv-status { padding: 3px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
|
660
|
+
.sv-status-filled { background: #23863633; color: #3fb950; }
|
|
661
|
+
.sv-status-accepted { background: #1f6feb33; color: #58a6ff; }
|
|
662
|
+
.sv-status-rejected { background: #f8514933; color: #f85149; }
|
|
663
|
+
.sv-status-expired { background: #6e768133; color: #8b949e; }
|
|
664
|
+
.sv-status-cancelled { background: #6e768133; color: #8b949e; }
|
|
665
|
+
.sv-status-pending { background: #d29922; color: #0d1117; }
|
|
666
|
+
.sv-side-buy { color: #3fb950; }
|
|
667
|
+
.sv-side-sell { color: #f85149; }
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
SIGNAL_VALIDATION_SCRIPT = """
|
|
671
|
+
let currentRun = null;
|
|
672
|
+
let currentAction = null;
|
|
673
|
+
let currentSignal = null;
|
|
674
|
+
let signalData = {};
|
|
675
|
+
|
|
676
|
+
async function loadRuns() {
|
|
677
|
+
const select = document.getElementById('run-select');
|
|
678
|
+
const res = await fetch('/api/runs');
|
|
679
|
+
const data = await res.json();
|
|
680
|
+
select.innerHTML = '<option value="">Select a run...</option>' +
|
|
681
|
+
data.runs.map(r => `<option value="${r.run_id}">${r.run_id}</option>`).join('');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function onRunChange() {
|
|
685
|
+
const runId = document.getElementById('run-select').value;
|
|
686
|
+
if (!runId) {
|
|
687
|
+
currentRun = null;
|
|
688
|
+
signalData = {};
|
|
689
|
+
renderSidebar();
|
|
690
|
+
renderContent();
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
currentRun = runId;
|
|
694
|
+
currentAction = null;
|
|
695
|
+
currentSignal = null;
|
|
696
|
+
const res = await fetch(`/api/signals/${runId}`);
|
|
697
|
+
signalData = await res.json();
|
|
698
|
+
renderSidebar();
|
|
699
|
+
renderContent();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function renderSidebar() {
|
|
703
|
+
const container = document.getElementById('action-groups');
|
|
704
|
+
if (!currentRun || !signalData.actions) {
|
|
705
|
+
container.innerHTML = '<div class="sv-empty">Select a run to view signals</div>';
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const actions = signalData.action_types || [];
|
|
709
|
+
container.innerHTML = actions.map(action => {
|
|
710
|
+
const signals = signalData.actions[action] || {};
|
|
711
|
+
const signalNames = Object.keys(signals);
|
|
712
|
+
const totalCount = signalNames.reduce((sum, s) => sum + signals[s].length, 0);
|
|
713
|
+
const isOpen = currentAction === action ? 'open' : '';
|
|
714
|
+
const pillsHtml = signalNames.length > 0
|
|
715
|
+
? signalNames.map(sig => {
|
|
716
|
+
const isActive = currentAction === action && currentSignal === sig ? 'active' : '';
|
|
717
|
+
return `<span class="sv-signal-pill ${isActive}" onclick="selectSignal('${action}', '${sig}')">${sig}<span class="pill-count">(${signals[sig].length})</span></span>`;
|
|
718
|
+
}).join('')
|
|
719
|
+
: '<span style="color: #6e7681; font-size: 13px;">No signals</span>';
|
|
720
|
+
return `
|
|
721
|
+
<div class="sv-action-group ${isOpen}">
|
|
722
|
+
<div class="sv-action-header" onclick="toggleAction('${action}')">
|
|
723
|
+
<span>${action}</span>
|
|
724
|
+
<span>
|
|
725
|
+
<span class="count">${totalCount}</span>
|
|
726
|
+
<svg class="chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
727
|
+
</span>
|
|
728
|
+
</div>
|
|
729
|
+
<div class="sv-action-signals">${pillsHtml}</div>
|
|
730
|
+
</div>
|
|
731
|
+
`;
|
|
732
|
+
}).join('');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function toggleAction(action) {
|
|
736
|
+
if (currentAction === action) {
|
|
737
|
+
currentAction = null;
|
|
738
|
+
currentSignal = null;
|
|
739
|
+
} else {
|
|
740
|
+
currentAction = action;
|
|
741
|
+
currentSignal = null;
|
|
742
|
+
}
|
|
743
|
+
renderSidebar();
|
|
744
|
+
renderContent();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function selectSignal(action, signal) {
|
|
748
|
+
currentAction = action;
|
|
749
|
+
currentSignal = signal;
|
|
750
|
+
renderSidebar();
|
|
751
|
+
renderContent();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function formatTime(ts) {
|
|
755
|
+
if (!ts) return '-';
|
|
756
|
+
return ts.split('T')[0] + ' ' + ts.split('T')[1].split('.')[0];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function renderContent() {
|
|
760
|
+
const container = document.getElementById('sv-content');
|
|
761
|
+
if (!currentRun) {
|
|
762
|
+
container.innerHTML = '<div class="sv-empty">Select a run from the dropdown to begin</div>';
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (!currentAction || !currentSignal) {
|
|
766
|
+
container.innerHTML = '<div class="sv-empty">Select an action and signal from the sidebar</div>';
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const orders = signalData.actions[currentAction]?.[currentSignal] || [];
|
|
770
|
+
if (orders.length === 0) {
|
|
771
|
+
container.innerHTML = '<div class="sv-empty">No orders for this signal</div>';
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
container.innerHTML = `
|
|
775
|
+
<div class="sv-content-header">
|
|
776
|
+
<h2>${currentSignal}</h2>
|
|
777
|
+
<div class="subtitle">Action: ${currentAction} • ${orders.length} order(s)</div>
|
|
778
|
+
</div>
|
|
779
|
+
<table class="sv-table">
|
|
780
|
+
<thead>
|
|
781
|
+
<tr>
|
|
782
|
+
<th>Time</th>
|
|
783
|
+
<th>Symbol</th>
|
|
784
|
+
<th>Side</th>
|
|
785
|
+
<th>Type</th>
|
|
786
|
+
<th>Qty</th>
|
|
787
|
+
<th>Price</th>
|
|
788
|
+
<th>Status</th>
|
|
789
|
+
</tr>
|
|
790
|
+
</thead>
|
|
791
|
+
<tbody>
|
|
792
|
+
${orders.map(o => `
|
|
793
|
+
<tr onclick="showSignalChart('${o.order_id}', '${o.symbol}')">
|
|
794
|
+
<td>${formatTime(o.ts_event)}</td>
|
|
795
|
+
<td style="font-family: monospace;">${o.symbol}</td>
|
|
796
|
+
<td class="sv-side-${o.side.toLowerCase()}">${o.side}</td>
|
|
797
|
+
<td>${o.order_type || '-'}</td>
|
|
798
|
+
<td>${o.quantity || '-'}</td>
|
|
799
|
+
<td>${o.limit_price ? '$' + o.limit_price.toFixed(2) : (o.stop_price ? 'Stop $' + o.stop_price.toFixed(2) : 'MKT')}</td>
|
|
800
|
+
<td><span class="sv-status sv-status-${o.status.toLowerCase()}">${o.status}</span></td>
|
|
801
|
+
</tr>
|
|
802
|
+
`).join('')}
|
|
803
|
+
</tbody>
|
|
804
|
+
</table>
|
|
805
|
+
`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function showSignalChart(orderId, symbol) {
|
|
809
|
+
const modal = document.getElementById('chart-modal');
|
|
810
|
+
const modalTitle = document.getElementById('chart-modal-title');
|
|
811
|
+
const modalBody = document.getElementById('chart-modal-body');
|
|
812
|
+
modalTitle.textContent = `Signal Chart - ${symbol}`;
|
|
813
|
+
modalBody.innerHTML = '<div style="text-align: center; padding: 40px;"><p>Generating chart...</p></div>';
|
|
814
|
+
modal.style.display = 'flex';
|
|
815
|
+
fetch(`/api/signals/${currentRun}/chart/${orderId}`)
|
|
816
|
+
.then(response => {
|
|
817
|
+
if (!response.ok) {
|
|
818
|
+
return response.json().then(data => { throw new Error(data.detail || 'Failed to load chart'); });
|
|
819
|
+
}
|
|
820
|
+
return response.blob();
|
|
821
|
+
})
|
|
822
|
+
.then(blob => {
|
|
823
|
+
const url = URL.createObjectURL(blob);
|
|
824
|
+
modalBody.innerHTML = `<img src="${url}" style="max-width: 100%; max-height: 80vh;">`;
|
|
825
|
+
})
|
|
826
|
+
.catch(error => {
|
|
827
|
+
modalBody.innerHTML = `<div style="color: #f85149; padding: 20px;">${error.message}</div>`;
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function closeChartModal() {
|
|
832
|
+
document.getElementById('chart-modal').style.display = 'none';
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
document.addEventListener('DOMContentLoaded', loadRuns);
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@app.get("/run/{run_id}", response_class=HTMLResponse)
|
|
840
|
+
async def run_detail_page(run_id: str):
|
|
841
|
+
sidebar = SIDEBAR_HTML.format(
|
|
842
|
+
runs_active="active",
|
|
843
|
+
backtest_active="",
|
|
844
|
+
secmaster_active="",
|
|
845
|
+
pipeline_active="",
|
|
846
|
+
pipeline_open="",
|
|
847
|
+
pipeline_sub1_active="",
|
|
848
|
+
pipeline_sub2_active="",
|
|
849
|
+
)
|
|
850
|
+
return f"""
|
|
851
|
+
<!DOCTYPE html>
|
|
852
|
+
<html>
|
|
853
|
+
<head>
|
|
854
|
+
<title>Run Detail - OneSecondTrader</title>
|
|
855
|
+
<style>{BASE_STYLE}{RUN_DETAIL_STYLE}</style>
|
|
856
|
+
</head>
|
|
857
|
+
<body>
|
|
858
|
+
{sidebar}
|
|
859
|
+
<main class="main-content">
|
|
860
|
+
<div class="container">
|
|
861
|
+
<div id="run-content">
|
|
862
|
+
<p style="color: #8b949e;">Loading...</p>
|
|
863
|
+
</div>
|
|
864
|
+
</div>
|
|
865
|
+
</main>
|
|
866
|
+
<div id="chart-modal" class="chart-modal" onclick="if(event.target===this)closeChartModal()">
|
|
867
|
+
<div class="chart-modal-content">
|
|
868
|
+
<div class="chart-modal-header">
|
|
869
|
+
<h3 id="chart-modal-title">Position Chart</h3>
|
|
870
|
+
<button class="chart-modal-close" onclick="closeChartModal()">×</button>
|
|
871
|
+
</div>
|
|
872
|
+
<div id="chart-modal-body" class="chart-modal-body"></div>
|
|
873
|
+
</div>
|
|
874
|
+
</div>
|
|
875
|
+
<script>{RUN_DETAIL_SCRIPT}</script>
|
|
876
|
+
</body>
|
|
877
|
+
</html>
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _get_runs_db_path() -> str:
|
|
882
|
+
return os.environ.get("RUNS_DB_PATH", "runs.db")
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _get_recent_runs(limit: int = 50) -> list[dict]:
|
|
886
|
+
import json
|
|
887
|
+
|
|
888
|
+
db_path = _get_runs_db_path()
|
|
889
|
+
if not os.path.exists(db_path):
|
|
890
|
+
return []
|
|
891
|
+
|
|
892
|
+
conn = sqlite3.connect(db_path)
|
|
893
|
+
cursor = conn.cursor()
|
|
894
|
+
cursor.execute(
|
|
895
|
+
"""
|
|
896
|
+
SELECT
|
|
897
|
+
r.run_id,
|
|
898
|
+
r.strategy,
|
|
899
|
+
r.symbols,
|
|
900
|
+
r.bar_period,
|
|
901
|
+
r.mode,
|
|
902
|
+
r.status,
|
|
903
|
+
r.created_at,
|
|
904
|
+
r.completed_at,
|
|
905
|
+
COUNT(b.id) as bar_count,
|
|
906
|
+
MIN(b.ts_event) as first_bar,
|
|
907
|
+
MAX(b.ts_event) as last_bar
|
|
908
|
+
FROM runs r
|
|
909
|
+
LEFT JOIN bars b ON r.run_id = b.run_id
|
|
910
|
+
GROUP BY r.run_id
|
|
911
|
+
ORDER BY r.created_at DESC
|
|
912
|
+
LIMIT ?
|
|
913
|
+
""",
|
|
914
|
+
(limit,),
|
|
915
|
+
)
|
|
916
|
+
runs = []
|
|
917
|
+
for row in cursor.fetchall():
|
|
918
|
+
runs.append(
|
|
919
|
+
{
|
|
920
|
+
"run_id": row[0],
|
|
921
|
+
"strategy": row[1],
|
|
922
|
+
"symbols": json.loads(row[2]) if row[2] else [],
|
|
923
|
+
"bar_period": _normalize_bar_period(row[3]),
|
|
924
|
+
"mode": row[4],
|
|
925
|
+
"status": row[5],
|
|
926
|
+
"created_at": row[6],
|
|
927
|
+
"completed_at": row[7],
|
|
928
|
+
"bar_count": row[8],
|
|
929
|
+
"first_bar": row[9],
|
|
930
|
+
"last_bar": row[10],
|
|
931
|
+
}
|
|
932
|
+
)
|
|
933
|
+
conn.close()
|
|
934
|
+
return runs
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@app.get("/api/runs")
|
|
938
|
+
async def list_runs(limit: int = 50):
|
|
939
|
+
return {"runs": _get_recent_runs(limit)}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _get_run_by_id(run_id: str) -> dict | None:
|
|
943
|
+
import json
|
|
944
|
+
|
|
945
|
+
db_path = _get_runs_db_path()
|
|
946
|
+
if not os.path.exists(db_path):
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
conn = sqlite3.connect(db_path)
|
|
950
|
+
cursor = conn.cursor()
|
|
951
|
+
cursor.execute(
|
|
952
|
+
"""
|
|
953
|
+
SELECT
|
|
954
|
+
r.run_id,
|
|
955
|
+
r.strategy,
|
|
956
|
+
r.symbols,
|
|
957
|
+
r.bar_period,
|
|
958
|
+
r.mode,
|
|
959
|
+
r.status,
|
|
960
|
+
r.created_at,
|
|
961
|
+
r.completed_at,
|
|
962
|
+
COUNT(b.id) as bar_count,
|
|
963
|
+
MIN(b.ts_event) as first_bar,
|
|
964
|
+
MAX(b.ts_event) as last_bar
|
|
965
|
+
FROM runs r
|
|
966
|
+
LEFT JOIN bars b ON r.run_id = b.run_id
|
|
967
|
+
WHERE r.run_id = ?
|
|
968
|
+
GROUP BY r.run_id
|
|
969
|
+
""",
|
|
970
|
+
(run_id,),
|
|
971
|
+
)
|
|
972
|
+
row = cursor.fetchone()
|
|
973
|
+
conn.close()
|
|
974
|
+
if not row:
|
|
975
|
+
return None
|
|
976
|
+
return {
|
|
977
|
+
"run_id": row[0],
|
|
978
|
+
"strategy": row[1],
|
|
979
|
+
"symbols": json.loads(row[2]) if row[2] else [],
|
|
980
|
+
"bar_period": _normalize_bar_period(row[3]),
|
|
981
|
+
"mode": row[4],
|
|
982
|
+
"status": row[5],
|
|
983
|
+
"created_at": row[6],
|
|
984
|
+
"completed_at": row[7],
|
|
985
|
+
"bar_count": row[8],
|
|
986
|
+
"first_bar": row[9],
|
|
987
|
+
"last_bar": row[10],
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@app.get("/api/run/{run_id}")
|
|
992
|
+
async def get_run(run_id: str):
|
|
993
|
+
run = _get_run_by_id(run_id)
|
|
994
|
+
if not run:
|
|
995
|
+
from fastapi import HTTPException
|
|
996
|
+
|
|
997
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
998
|
+
return run
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@app.delete("/api/run/{run_id}")
|
|
1002
|
+
async def delete_run(run_id: str):
|
|
1003
|
+
db_path = _get_runs_db_path()
|
|
1004
|
+
if not os.path.exists(db_path):
|
|
1005
|
+
from fastapi import HTTPException
|
|
1006
|
+
|
|
1007
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
1008
|
+
conn = sqlite3.connect(db_path)
|
|
1009
|
+
cursor = conn.cursor()
|
|
1010
|
+
cursor.execute("DELETE FROM bars WHERE run_id = ?", (run_id,))
|
|
1011
|
+
cursor.execute("DELETE FROM order_requests WHERE run_id = ?", (run_id,))
|
|
1012
|
+
cursor.execute("DELETE FROM order_responses WHERE run_id = ?", (run_id,))
|
|
1013
|
+
cursor.execute("DELETE FROM fills WHERE run_id = ?", (run_id,))
|
|
1014
|
+
cursor.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
|
1015
|
+
conn.commit()
|
|
1016
|
+
conn.close()
|
|
1017
|
+
charts_path = CHARTS_DIR / run_id
|
|
1018
|
+
if charts_path.exists():
|
|
1019
|
+
shutil.rmtree(charts_path)
|
|
1020
|
+
return {"status": "deleted"}
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _normalize_side(side) -> str:
|
|
1024
|
+
if side in ("BUY", 1, "1"):
|
|
1025
|
+
return "BUY"
|
|
1026
|
+
return "SELL"
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _extract_positions(fills: list[dict]) -> list[dict]:
|
|
1030
|
+
if not fills:
|
|
1031
|
+
return []
|
|
1032
|
+
for f in fills:
|
|
1033
|
+
f["side"] = _normalize_side(f["side"])
|
|
1034
|
+
positions = []
|
|
1035
|
+
by_symbol: dict[str, list[dict]] = {}
|
|
1036
|
+
for f in fills:
|
|
1037
|
+
by_symbol.setdefault(f["symbol"], []).append(f)
|
|
1038
|
+
for symbol, symbol_fills in by_symbol.items():
|
|
1039
|
+
symbol_fills.sort(key=lambda x: x["ts_event"])
|
|
1040
|
+
position = 0.0
|
|
1041
|
+
position_fills: list[dict] = []
|
|
1042
|
+
position_id = 0
|
|
1043
|
+
for fill in symbol_fills:
|
|
1044
|
+
qty = fill["quantity"]
|
|
1045
|
+
signed_qty = qty if fill["side"] == "BUY" else -qty
|
|
1046
|
+
new_position = position + signed_qty
|
|
1047
|
+
position_fills.append(fill)
|
|
1048
|
+
if new_position == 0.0 and position != 0.0:
|
|
1049
|
+
position_id += 1
|
|
1050
|
+
pnl = 0.0
|
|
1051
|
+
total_commission = 0.0
|
|
1052
|
+
for pf in position_fills:
|
|
1053
|
+
value = pf["price"] * pf["quantity"]
|
|
1054
|
+
commission = pf.get("commission") or 0.0
|
|
1055
|
+
total_commission += commission
|
|
1056
|
+
if pf["side"] == "BUY":
|
|
1057
|
+
pnl -= value
|
|
1058
|
+
else:
|
|
1059
|
+
pnl += value
|
|
1060
|
+
pnl -= total_commission
|
|
1061
|
+
entry_fill = position_fills[0]
|
|
1062
|
+
exit_fill = position_fills[-1]
|
|
1063
|
+
entry_side = entry_fill["side"]
|
|
1064
|
+
entry_qty = sum(
|
|
1065
|
+
pf["quantity"] for pf in position_fills if pf["side"] == entry_side
|
|
1066
|
+
)
|
|
1067
|
+
entry_value = sum(
|
|
1068
|
+
pf["quantity"] * pf["price"]
|
|
1069
|
+
for pf in position_fills
|
|
1070
|
+
if pf["side"] == entry_side
|
|
1071
|
+
)
|
|
1072
|
+
avg_entry = entry_value / entry_qty if entry_qty else 0
|
|
1073
|
+
exit_side = "SELL" if entry_side == "BUY" else "BUY"
|
|
1074
|
+
exit_qty = sum(
|
|
1075
|
+
pf["quantity"] for pf in position_fills if pf["side"] == exit_side
|
|
1076
|
+
)
|
|
1077
|
+
exit_value = sum(
|
|
1078
|
+
pf["quantity"] * pf["price"]
|
|
1079
|
+
for pf in position_fills
|
|
1080
|
+
if pf["side"] == exit_side
|
|
1081
|
+
)
|
|
1082
|
+
avg_exit = exit_value / exit_qty if exit_qty else 0
|
|
1083
|
+
positions.append(
|
|
1084
|
+
{
|
|
1085
|
+
"position_id": position_id,
|
|
1086
|
+
"symbol": symbol,
|
|
1087
|
+
"side": "LONG" if entry_side == "BUY" else "SHORT",
|
|
1088
|
+
"quantity": entry_qty,
|
|
1089
|
+
"entry_time": entry_fill["ts_event"],
|
|
1090
|
+
"exit_time": exit_fill["ts_event"],
|
|
1091
|
+
"avg_entry_price": avg_entry,
|
|
1092
|
+
"avg_exit_price": avg_exit,
|
|
1093
|
+
"pnl": pnl,
|
|
1094
|
+
"commission": total_commission,
|
|
1095
|
+
"num_fills": len(position_fills),
|
|
1096
|
+
}
|
|
1097
|
+
)
|
|
1098
|
+
position_fills = []
|
|
1099
|
+
position = new_position
|
|
1100
|
+
positions.sort(key=lambda x: x["entry_time"])
|
|
1101
|
+
return positions
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
@app.get("/api/run/{run_id}/positions")
|
|
1105
|
+
async def get_run_positions(run_id: str):
|
|
1106
|
+
db_path = _get_runs_db_path()
|
|
1107
|
+
if not os.path.exists(db_path):
|
|
1108
|
+
return {"positions": []}
|
|
1109
|
+
conn = sqlite3.connect(db_path)
|
|
1110
|
+
cursor = conn.cursor()
|
|
1111
|
+
cursor.execute(
|
|
1112
|
+
"""
|
|
1113
|
+
SELECT ts_event, symbol, side, quantity, price, commission
|
|
1114
|
+
FROM fills WHERE run_id = ? ORDER BY ts_event
|
|
1115
|
+
""",
|
|
1116
|
+
(run_id,),
|
|
1117
|
+
)
|
|
1118
|
+
rows = cursor.fetchall()
|
|
1119
|
+
conn.close()
|
|
1120
|
+
fills = [
|
|
1121
|
+
{
|
|
1122
|
+
"ts_event": row[0],
|
|
1123
|
+
"symbol": row[1],
|
|
1124
|
+
"side": row[2],
|
|
1125
|
+
"quantity": row[3],
|
|
1126
|
+
"price": row[4],
|
|
1127
|
+
"commission": row[5],
|
|
1128
|
+
}
|
|
1129
|
+
for row in rows
|
|
1130
|
+
]
|
|
1131
|
+
positions = _extract_positions(fills)
|
|
1132
|
+
total_pnl = sum(p["pnl"] for p in positions)
|
|
1133
|
+
winners = sum(1 for p in positions if p["pnl"] > 0)
|
|
1134
|
+
losers = sum(1 for p in positions if p["pnl"] < 0)
|
|
1135
|
+
return {
|
|
1136
|
+
"positions": positions,
|
|
1137
|
+
"summary": {
|
|
1138
|
+
"total_positions": len(positions),
|
|
1139
|
+
"total_pnl": total_pnl,
|
|
1140
|
+
"winners": winners,
|
|
1141
|
+
"losers": losers,
|
|
1142
|
+
"win_rate": winners / len(positions) if positions else 0,
|
|
1143
|
+
},
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _load_bars_for_chart(run_id: str, symbol: str) -> pd.DataFrame | None:
|
|
1148
|
+
db_path = _get_runs_db_path()
|
|
1149
|
+
if not os.path.exists(db_path):
|
|
1150
|
+
return None
|
|
1151
|
+
conn = sqlite3.connect(db_path)
|
|
1152
|
+
bars_df = pd.read_sql(
|
|
1153
|
+
"SELECT * FROM bars WHERE run_id = ? AND symbol = ? ORDER BY ts_event",
|
|
1154
|
+
conn,
|
|
1155
|
+
params=(run_id, symbol),
|
|
1156
|
+
)
|
|
1157
|
+
conn.close()
|
|
1158
|
+
if bars_df.empty:
|
|
1159
|
+
return None
|
|
1160
|
+
bars_df["ts_event"] = pd.to_datetime(bars_df["ts_event"])
|
|
1161
|
+
if "indicators" in bars_df.columns:
|
|
1162
|
+
indicator_rows = []
|
|
1163
|
+
for _, row in bars_df.iterrows():
|
|
1164
|
+
indicators_json = row.get("indicators")
|
|
1165
|
+
if indicators_json and isinstance(indicators_json, str):
|
|
1166
|
+
try:
|
|
1167
|
+
indicators = json.loads(indicators_json)
|
|
1168
|
+
indicator_rows.append(indicators)
|
|
1169
|
+
except json.JSONDecodeError:
|
|
1170
|
+
indicator_rows.append({})
|
|
1171
|
+
else:
|
|
1172
|
+
indicator_rows.append({})
|
|
1173
|
+
if indicator_rows:
|
|
1174
|
+
indicators_df = pd.DataFrame(indicator_rows)
|
|
1175
|
+
for col in indicators_df.columns:
|
|
1176
|
+
bars_df[col] = indicators_df[col].values
|
|
1177
|
+
return bars_df
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _load_fills_for_chart(run_id: str, symbol: str) -> list[dict]:
|
|
1181
|
+
db_path = _get_runs_db_path()
|
|
1182
|
+
if not os.path.exists(db_path):
|
|
1183
|
+
return []
|
|
1184
|
+
conn = sqlite3.connect(db_path)
|
|
1185
|
+
cursor = conn.cursor()
|
|
1186
|
+
cursor.execute(
|
|
1187
|
+
"""SELECT ts_event, symbol, side, quantity, price, commission, order_id
|
|
1188
|
+
FROM fills WHERE run_id = ? AND symbol = ? ORDER BY ts_event""",
|
|
1189
|
+
(run_id, symbol),
|
|
1190
|
+
)
|
|
1191
|
+
rows = cursor.fetchall()
|
|
1192
|
+
conn.close()
|
|
1193
|
+
return [
|
|
1194
|
+
{
|
|
1195
|
+
"ts_event": pd.to_datetime(row[0]),
|
|
1196
|
+
"symbol": row[1],
|
|
1197
|
+
"side": row[2],
|
|
1198
|
+
"quantity": row[3],
|
|
1199
|
+
"price": row[4],
|
|
1200
|
+
"commission": row[5] or 0.0,
|
|
1201
|
+
"order_id": row[6],
|
|
1202
|
+
}
|
|
1203
|
+
for row in rows
|
|
1204
|
+
]
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
_OHLCV_COLUMNS = {
|
|
1208
|
+
"ts_event",
|
|
1209
|
+
"symbol",
|
|
1210
|
+
"bar_period",
|
|
1211
|
+
"open",
|
|
1212
|
+
"high",
|
|
1213
|
+
"low",
|
|
1214
|
+
"close",
|
|
1215
|
+
"volume",
|
|
1216
|
+
"run_id",
|
|
1217
|
+
"indicators",
|
|
1218
|
+
}
|
|
1219
|
+
_MAIN_COLORS = ["orange", "purple", "brown", "pink", "gray", "cyan", "magenta"]
|
|
1220
|
+
_SUBPLOT_COLORS = [
|
|
1221
|
+
"#1f77b4",
|
|
1222
|
+
"#ff7f0e",
|
|
1223
|
+
"#2ca02c",
|
|
1224
|
+
"#d62728",
|
|
1225
|
+
"#9467bd",
|
|
1226
|
+
"#8c564b",
|
|
1227
|
+
"#e377c2",
|
|
1228
|
+
"#7f7f7f",
|
|
1229
|
+
]
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _parse_indicator_column(col: str) -> tuple[int, str] | None:
|
|
1233
|
+
if col in _OHLCV_COLUMNS or len(col) < 4 or col[2] != "_":
|
|
1234
|
+
return None
|
|
1235
|
+
try:
|
|
1236
|
+
plot_at = int(col[:2])
|
|
1237
|
+
display_name = col[3:]
|
|
1238
|
+
return (plot_at, display_name)
|
|
1239
|
+
except ValueError:
|
|
1240
|
+
return None
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _get_indicator_columns(data: pd.DataFrame) -> dict[str, tuple[int, str]]:
|
|
1244
|
+
result = {}
|
|
1245
|
+
for col in data.columns:
|
|
1246
|
+
parsed = _parse_indicator_column(col)
|
|
1247
|
+
if parsed is not None:
|
|
1248
|
+
result[col] = parsed
|
|
1249
|
+
return result
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _main_indicator_columns(data: pd.DataFrame) -> list[tuple[str, str]]:
|
|
1253
|
+
indicators = _get_indicator_columns(data)
|
|
1254
|
+
return [(col, name) for col, (plot_at, name) in indicators.items() if plot_at == 0]
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _subplot_groups_from_data(data: pd.DataFrame) -> dict[int, list[tuple[str, str]]]:
|
|
1258
|
+
indicators = _get_indicator_columns(data)
|
|
1259
|
+
groups: dict[int, list[tuple[str, str]]] = {}
|
|
1260
|
+
for col, (plot_at, name) in indicators.items():
|
|
1261
|
+
if 1 <= plot_at <= 98:
|
|
1262
|
+
groups.setdefault(plot_at, []).append((col, name))
|
|
1263
|
+
return groups
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _extract_positions_for_chart(run_id: str) -> list[dict]:
|
|
1267
|
+
db_path = _get_runs_db_path()
|
|
1268
|
+
if not os.path.exists(db_path):
|
|
1269
|
+
return []
|
|
1270
|
+
conn = sqlite3.connect(db_path)
|
|
1271
|
+
cursor = conn.cursor()
|
|
1272
|
+
cursor.execute(
|
|
1273
|
+
"""SELECT ts_event, symbol, side, quantity, price, commission, order_id
|
|
1274
|
+
FROM fills WHERE run_id = ? ORDER BY ts_event""",
|
|
1275
|
+
(run_id,),
|
|
1276
|
+
)
|
|
1277
|
+
rows = cursor.fetchall()
|
|
1278
|
+
conn.close()
|
|
1279
|
+
fills = [
|
|
1280
|
+
{
|
|
1281
|
+
"ts_event": pd.to_datetime(row[0]),
|
|
1282
|
+
"symbol": row[1],
|
|
1283
|
+
"side": _normalize_side(row[2]),
|
|
1284
|
+
"quantity": row[3],
|
|
1285
|
+
"price": row[4],
|
|
1286
|
+
"commission": row[5] or 0.0,
|
|
1287
|
+
"order_id": row[6],
|
|
1288
|
+
}
|
|
1289
|
+
for row in rows
|
|
1290
|
+
]
|
|
1291
|
+
by_symbol: dict[str, list] = {}
|
|
1292
|
+
for f in fills:
|
|
1293
|
+
by_symbol.setdefault(f["symbol"], []).append(f)
|
|
1294
|
+
positions = []
|
|
1295
|
+
for symbol, symbol_fills in by_symbol.items():
|
|
1296
|
+
symbol_fills.sort(key=lambda x: x["ts_event"])
|
|
1297
|
+
position = 0.0
|
|
1298
|
+
position_fills: list = []
|
|
1299
|
+
position_id = 0
|
|
1300
|
+
for fill in symbol_fills:
|
|
1301
|
+
qty = fill["quantity"]
|
|
1302
|
+
signed_qty = qty if fill["side"] == "BUY" else -qty
|
|
1303
|
+
new_position = position + signed_qty
|
|
1304
|
+
position_fills.append(fill)
|
|
1305
|
+
if new_position == 0.0 and position != 0.0:
|
|
1306
|
+
position_id += 1
|
|
1307
|
+
pnl = 0.0
|
|
1308
|
+
total_commission = 0.0
|
|
1309
|
+
for pf in position_fills:
|
|
1310
|
+
value = pf["price"] * pf["quantity"]
|
|
1311
|
+
commission = pf.get("commission") or 0.0
|
|
1312
|
+
total_commission += commission
|
|
1313
|
+
if pf["side"] == "BUY":
|
|
1314
|
+
pnl -= value
|
|
1315
|
+
else:
|
|
1316
|
+
pnl += value
|
|
1317
|
+
pnl -= total_commission
|
|
1318
|
+
positions.append(
|
|
1319
|
+
{
|
|
1320
|
+
"position_id": position_id,
|
|
1321
|
+
"symbol": symbol,
|
|
1322
|
+
"fills": list(position_fills),
|
|
1323
|
+
"first_fill_time": position_fills[0]["ts_event"],
|
|
1324
|
+
"last_fill_time": position_fills[-1]["ts_event"],
|
|
1325
|
+
"pnl": pnl,
|
|
1326
|
+
}
|
|
1327
|
+
)
|
|
1328
|
+
position_fills = []
|
|
1329
|
+
position = new_position
|
|
1330
|
+
positions.sort(key=lambda x: x["first_fill_time"])
|
|
1331
|
+
return positions
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _format_indicator_name(raw_name: str) -> str:
|
|
1335
|
+
name_with_spaces = raw_name.replace("_", " ")
|
|
1336
|
+
words = name_with_spaces.split()
|
|
1337
|
+
formatted_words = []
|
|
1338
|
+
for word in words:
|
|
1339
|
+
if word.isupper():
|
|
1340
|
+
formatted_words.append(word)
|
|
1341
|
+
elif word.islower():
|
|
1342
|
+
formatted_words.append(word.capitalize())
|
|
1343
|
+
else:
|
|
1344
|
+
formatted_words.append(word)
|
|
1345
|
+
return " ".join(formatted_words)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _generate_position_chart(
|
|
1349
|
+
output_path: pathlib.Path,
|
|
1350
|
+
data: pd.DataFrame,
|
|
1351
|
+
target_pos: dict,
|
|
1352
|
+
symbol: str,
|
|
1353
|
+
position_id: int,
|
|
1354
|
+
highlight_start: int,
|
|
1355
|
+
highlight_end: int,
|
|
1356
|
+
) -> None:
|
|
1357
|
+
groups = _subplot_groups_from_data(data)
|
|
1358
|
+
n = 2 + len(groups)
|
|
1359
|
+
ratios = [1, 4] + [1] * len(groups)
|
|
1360
|
+
fig, axes = plt.subplots(
|
|
1361
|
+
n, 1, figsize=(16, 12), sharex=True, gridspec_kw={"height_ratios": ratios}
|
|
1362
|
+
)
|
|
1363
|
+
axes = [axes] if n == 1 else list(axes)
|
|
1364
|
+
|
|
1365
|
+
ax_pnl = axes[0]
|
|
1366
|
+
ax_main = axes[1]
|
|
1367
|
+
|
|
1368
|
+
_plot_pnl_tracking(ax_pnl, data, target_pos, highlight_start, highlight_end)
|
|
1369
|
+
_plot_price_data(ax_main, data, highlight_start, highlight_end)
|
|
1370
|
+
_plot_main_indicators(ax_main, data)
|
|
1371
|
+
_plot_trade_markers(ax_main, target_pos)
|
|
1372
|
+
|
|
1373
|
+
for i, (_, ind_cols) in enumerate(sorted(groups.items())):
|
|
1374
|
+
ax_sub = axes[i + 2]
|
|
1375
|
+
for j, (col, display_name) in enumerate(ind_cols):
|
|
1376
|
+
if col in data.columns:
|
|
1377
|
+
ax_sub.plot(
|
|
1378
|
+
data["ts_event"],
|
|
1379
|
+
data[col],
|
|
1380
|
+
label=display_name,
|
|
1381
|
+
linewidth=1.5,
|
|
1382
|
+
alpha=0.8,
|
|
1383
|
+
color=_SUBPLOT_COLORS[j % len(_SUBPLOT_COLORS)],
|
|
1384
|
+
)
|
|
1385
|
+
ax_sub.legend(loc="upper left", fontsize=8)
|
|
1386
|
+
ax_sub.grid(True, alpha=0.3)
|
|
1387
|
+
|
|
1388
|
+
label = (
|
|
1389
|
+
"WIN"
|
|
1390
|
+
if target_pos["pnl"] > 0
|
|
1391
|
+
else "LOSS"
|
|
1392
|
+
if target_pos["pnl"] < 0
|
|
1393
|
+
else "BREAK-EVEN"
|
|
1394
|
+
)
|
|
1395
|
+
duration_secs = (
|
|
1396
|
+
target_pos["last_fill_time"] - target_pos["first_fill_time"]
|
|
1397
|
+
).total_seconds()
|
|
1398
|
+
duration_mins = duration_secs / 60
|
|
1399
|
+
ax_pnl.set_title(
|
|
1400
|
+
f"Position #{position_id} - {symbol} - {label} - P&L: ${target_pos['pnl']:.2f} - Duration: {duration_mins:.0f}min",
|
|
1401
|
+
fontsize=14,
|
|
1402
|
+
fontweight="bold",
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
total_seconds = (
|
|
1406
|
+
data["ts_event"].iloc[-1] - data["ts_event"].iloc[0]
|
|
1407
|
+
).total_seconds()
|
|
1408
|
+
hours = total_seconds / 3600
|
|
1409
|
+
days = total_seconds / 86400
|
|
1410
|
+
|
|
1411
|
+
for a in axes:
|
|
1412
|
+
if days > 30:
|
|
1413
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
|
|
1414
|
+
a.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
|
|
1415
|
+
elif days > 7:
|
|
1416
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
|
|
1417
|
+
a.xaxis.set_major_locator(mdates.DayLocator(interval=2))
|
|
1418
|
+
elif days > 2:
|
|
1419
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
|
|
1420
|
+
a.xaxis.set_major_locator(mdates.DayLocator(interval=1))
|
|
1421
|
+
elif hours > 24:
|
|
1422
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d %H:%M"))
|
|
1423
|
+
a.xaxis.set_major_locator(mdates.HourLocator(interval=6))
|
|
1424
|
+
elif hours > 8:
|
|
1425
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
1426
|
+
a.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
|
1427
|
+
elif hours > 2:
|
|
1428
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
1429
|
+
a.xaxis.set_major_locator(mdates.HourLocator(interval=1))
|
|
1430
|
+
elif hours > 0.5:
|
|
1431
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
1432
|
+
a.xaxis.set_major_locator(mdates.MinuteLocator(interval=15))
|
|
1433
|
+
else:
|
|
1434
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
|
|
1435
|
+
a.xaxis.set_major_locator(mdates.MinuteLocator(interval=5))
|
|
1436
|
+
|
|
1437
|
+
plt.xticks(rotation=45, fontsize=9)
|
|
1438
|
+
plt.tight_layout()
|
|
1439
|
+
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
|
1440
|
+
plt.close(fig)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _plot_pnl_tracking(
|
|
1444
|
+
ax, data: pd.DataFrame, target_pos: dict, highlight_start: int, highlight_end: int
|
|
1445
|
+
) -> None:
|
|
1446
|
+
pnl_series = []
|
|
1447
|
+
max_pnl = 0
|
|
1448
|
+
drawdown_series = []
|
|
1449
|
+
|
|
1450
|
+
entry_price = target_pos["fills"][0]["price"] if target_pos["fills"] else 0
|
|
1451
|
+
direction = target_pos["fills"][0]["side"] if target_pos["fills"] else "BUY"
|
|
1452
|
+
in_position = False
|
|
1453
|
+
|
|
1454
|
+
for i in range(len(data)):
|
|
1455
|
+
ts = data["ts_event"].iloc[i]
|
|
1456
|
+
close = data["close"].iloc[i]
|
|
1457
|
+
|
|
1458
|
+
if ts >= target_pos["first_fill_time"] and ts <= target_pos["last_fill_time"]:
|
|
1459
|
+
in_position = True
|
|
1460
|
+
if direction == "BUY":
|
|
1461
|
+
pnl = close - entry_price
|
|
1462
|
+
else:
|
|
1463
|
+
pnl = entry_price - close
|
|
1464
|
+
else:
|
|
1465
|
+
in_position = False
|
|
1466
|
+
pnl = 0
|
|
1467
|
+
|
|
1468
|
+
if in_position:
|
|
1469
|
+
max_pnl = max(max_pnl, pnl)
|
|
1470
|
+
drawdown = pnl - max_pnl
|
|
1471
|
+
else:
|
|
1472
|
+
drawdown = 0
|
|
1473
|
+
|
|
1474
|
+
pnl_series.append(pnl)
|
|
1475
|
+
drawdown_series.append(drawdown)
|
|
1476
|
+
|
|
1477
|
+
ax.plot(
|
|
1478
|
+
data["ts_event"],
|
|
1479
|
+
pnl_series,
|
|
1480
|
+
color="blue",
|
|
1481
|
+
linewidth=2,
|
|
1482
|
+
label="Unrealized P&L",
|
|
1483
|
+
alpha=0.8,
|
|
1484
|
+
)
|
|
1485
|
+
ax.fill_between(
|
|
1486
|
+
data["ts_event"], drawdown_series, 0, color="red", alpha=0.3, label="Drawdown"
|
|
1487
|
+
)
|
|
1488
|
+
ax.axhline(y=0, color="black", linestyle="-", alpha=0.5, linewidth=0.8)
|
|
1489
|
+
|
|
1490
|
+
if 0 <= highlight_start < len(data) and 0 <= highlight_end < len(data):
|
|
1491
|
+
start_time = mdates.date2num(data["ts_event"].iloc[highlight_start])
|
|
1492
|
+
end_time = mdates.date2num(data["ts_event"].iloc[highlight_end])
|
|
1493
|
+
y_min, y_max = ax.get_ylim()
|
|
1494
|
+
rect = Rectangle(
|
|
1495
|
+
(start_time, y_min),
|
|
1496
|
+
end_time - start_time,
|
|
1497
|
+
y_max - y_min,
|
|
1498
|
+
facecolor="lightblue",
|
|
1499
|
+
alpha=0.2,
|
|
1500
|
+
)
|
|
1501
|
+
ax.add_patch(rect)
|
|
1502
|
+
|
|
1503
|
+
ax.legend(loc="upper left", fontsize=8)
|
|
1504
|
+
ax.grid(True, alpha=0.3)
|
|
1505
|
+
ax.set_ylabel("P&L ($)", fontsize=10)
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _plot_price_data(
|
|
1509
|
+
ax, data: pd.DataFrame, highlight_start: int, highlight_end: int
|
|
1510
|
+
) -> None:
|
|
1511
|
+
for i in range(len(data)):
|
|
1512
|
+
date = data["ts_event"].iloc[i]
|
|
1513
|
+
ax.plot(
|
|
1514
|
+
[date, date],
|
|
1515
|
+
[data["low"].iloc[i], data["high"].iloc[i]],
|
|
1516
|
+
color="black",
|
|
1517
|
+
linewidth=0.8,
|
|
1518
|
+
alpha=0.7,
|
|
1519
|
+
)
|
|
1520
|
+
ax.plot([date], [data["close"].iloc[i]], marker="_", color="blue", markersize=3)
|
|
1521
|
+
|
|
1522
|
+
if 0 <= highlight_start < len(data) and 0 <= highlight_end < len(data):
|
|
1523
|
+
start_time = mdates.date2num(data["ts_event"].iloc[highlight_start])
|
|
1524
|
+
end_time = mdates.date2num(data["ts_event"].iloc[highlight_end])
|
|
1525
|
+
y_min, y_max = ax.get_ylim()
|
|
1526
|
+
rect = Rectangle(
|
|
1527
|
+
(start_time, y_min),
|
|
1528
|
+
end_time - start_time,
|
|
1529
|
+
y_max - y_min,
|
|
1530
|
+
facecolor="lightblue",
|
|
1531
|
+
alpha=0.2,
|
|
1532
|
+
label="Position Period",
|
|
1533
|
+
)
|
|
1534
|
+
ax.add_patch(rect)
|
|
1535
|
+
|
|
1536
|
+
ax.set_ylabel("Price", fontsize=10)
|
|
1537
|
+
ax.grid(True, alpha=0.3)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _plot_main_indicators(ax, data: pd.DataFrame) -> None:
|
|
1541
|
+
main_indicators = _main_indicator_columns(data)
|
|
1542
|
+
for i, (col, display_name) in enumerate(main_indicators):
|
|
1543
|
+
if col in data.columns:
|
|
1544
|
+
ax.plot(
|
|
1545
|
+
data["ts_event"],
|
|
1546
|
+
data[col],
|
|
1547
|
+
label=display_name,
|
|
1548
|
+
linewidth=1.5,
|
|
1549
|
+
alpha=0.8,
|
|
1550
|
+
color=_MAIN_COLORS[i % len(_MAIN_COLORS)],
|
|
1551
|
+
)
|
|
1552
|
+
if main_indicators:
|
|
1553
|
+
ax.legend(loc="upper right", fontsize=8)
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _plot_trade_markers(ax, target_pos: dict) -> None:
|
|
1557
|
+
for fill in target_pos["fills"]:
|
|
1558
|
+
marker = "^" if fill["side"] == "BUY" else "v"
|
|
1559
|
+
color = "green" if fill["side"] == "BUY" else "red"
|
|
1560
|
+
qty = fill.get("quantity", 1)
|
|
1561
|
+
base_size = 120
|
|
1562
|
+
quantity_multiplier = min(3.0, max(0.5, qty))
|
|
1563
|
+
size = base_size * quantity_multiplier
|
|
1564
|
+
|
|
1565
|
+
ax.scatter(
|
|
1566
|
+
fill["ts_event"],
|
|
1567
|
+
fill["price"],
|
|
1568
|
+
marker=marker,
|
|
1569
|
+
color=color,
|
|
1570
|
+
s=size,
|
|
1571
|
+
edgecolors="black",
|
|
1572
|
+
linewidth=1,
|
|
1573
|
+
zorder=5,
|
|
1574
|
+
alpha=0.8,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
y_lim = ax.get_ylim()
|
|
1578
|
+
offset_y = (y_lim[1] - y_lim[0]) * 0.02
|
|
1579
|
+
ax.annotate(
|
|
1580
|
+
f"{qty}",
|
|
1581
|
+
(fill["ts_event"], fill["price"] + offset_y),
|
|
1582
|
+
ha="center",
|
|
1583
|
+
va="bottom",
|
|
1584
|
+
fontsize=8,
|
|
1585
|
+
fontweight="bold",
|
|
1586
|
+
bbox=dict(boxstyle="round,pad=0.2", facecolor="white", alpha=0.7),
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
@app.get("/api/run/{run_id}/positions/{symbol}/{position_id}/chart")
|
|
1591
|
+
async def get_position_chart(run_id: str, symbol: str, position_id: int):
|
|
1592
|
+
from fastapi import HTTPException
|
|
1593
|
+
|
|
1594
|
+
chart_dir = CHARTS_DIR / run_id
|
|
1595
|
+
chart_path = chart_dir / f"{symbol}_{position_id}.png"
|
|
1596
|
+
|
|
1597
|
+
if chart_path.exists():
|
|
1598
|
+
return FileResponse(chart_path, media_type="image/png")
|
|
1599
|
+
|
|
1600
|
+
bars_df = _load_bars_for_chart(run_id, symbol)
|
|
1601
|
+
if bars_df is None or bars_df.empty:
|
|
1602
|
+
raise HTTPException(status_code=404, detail="No bar data found for this symbol")
|
|
1603
|
+
|
|
1604
|
+
all_positions = _extract_positions_for_chart(run_id)
|
|
1605
|
+
target_pos = None
|
|
1606
|
+
for p in all_positions:
|
|
1607
|
+
if p["symbol"] == symbol and p["position_id"] == position_id:
|
|
1608
|
+
target_pos = p
|
|
1609
|
+
break
|
|
1610
|
+
|
|
1611
|
+
if target_pos is None:
|
|
1612
|
+
raise HTTPException(
|
|
1613
|
+
status_code=404, detail=f"Position {position_id} not found for {symbol}"
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
bars_before, bars_after = 100, 100
|
|
1617
|
+
mask_start = bars_df["ts_event"] >= target_pos["first_fill_time"]
|
|
1618
|
+
mask_end = bars_df["ts_event"] <= target_pos["last_fill_time"]
|
|
1619
|
+
if not mask_start.any() or not mask_end.any():
|
|
1620
|
+
raise HTTPException(
|
|
1621
|
+
status_code=404, detail="Position time range not found in bar data"
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1624
|
+
start_idx = int(bars_df.index.get_loc(bars_df[mask_start].index[0])) # type: ignore[arg-type]
|
|
1625
|
+
end_idx = int(bars_df.index.get_loc(bars_df[mask_end].index[-1])) # type: ignore[arg-type]
|
|
1626
|
+
chart_start = max(0, start_idx - bars_before)
|
|
1627
|
+
chart_end = min(len(bars_df) - 1, end_idx + bars_after)
|
|
1628
|
+
data = bars_df.iloc[chart_start : chart_end + 1].copy()
|
|
1629
|
+
highlight_start = start_idx - chart_start
|
|
1630
|
+
highlight_end = end_idx - chart_start
|
|
1631
|
+
|
|
1632
|
+
chart_dir.mkdir(parents=True, exist_ok=True)
|
|
1633
|
+
_generate_position_chart(
|
|
1634
|
+
chart_path,
|
|
1635
|
+
data,
|
|
1636
|
+
target_pos,
|
|
1637
|
+
symbol,
|
|
1638
|
+
position_id,
|
|
1639
|
+
highlight_start,
|
|
1640
|
+
highlight_end,
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
return FileResponse(chart_path, media_type="image/png")
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
@app.get("/health")
|
|
1647
|
+
async def health():
|
|
1648
|
+
return {"status": "ok"}
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
@app.get("/api/strategies")
|
|
1652
|
+
async def list_strategies():
|
|
1653
|
+
strategies = []
|
|
1654
|
+
for class_name, cls in registry.get_strategies().items():
|
|
1655
|
+
display_name = getattr(cls, "name", "") or class_name
|
|
1656
|
+
strategies.append({"class_name": class_name, "display_name": display_name})
|
|
1657
|
+
return {"strategies": strategies}
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
@app.get("/api/brokers")
|
|
1661
|
+
async def list_brokers():
|
|
1662
|
+
return {"brokers": list(registry.get_brokers().keys())}
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
@app.get("/api/datafeeds")
|
|
1666
|
+
async def list_datafeeds():
|
|
1667
|
+
return {"datafeeds": list(registry.get_datafeeds().keys())}
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
@app.get("/api/strategies/{name}")
|
|
1671
|
+
async def get_strategy(name: str):
|
|
1672
|
+
schema = registry.get_strategy_schema(name)
|
|
1673
|
+
if schema is None:
|
|
1674
|
+
return {"error": "Strategy not found"}
|
|
1675
|
+
return schema
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
@app.get("/api/brokers/{name}")
|
|
1679
|
+
async def get_broker(name: str):
|
|
1680
|
+
schema = registry.get_broker_schema(name)
|
|
1681
|
+
if schema is None:
|
|
1682
|
+
return {"error": "Broker not found"}
|
|
1683
|
+
return schema
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
@app.get("/api/datafeeds/{name}")
|
|
1687
|
+
async def get_datafeed(name: str):
|
|
1688
|
+
schema = registry.get_datafeed_schema(name)
|
|
1689
|
+
if schema is None:
|
|
1690
|
+
return {"error": "Datafeed not found"}
|
|
1691
|
+
return schema
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
@app.get("/api/secmaster/symbols")
|
|
1695
|
+
async def get_symbols(bar_period: str | None = None):
|
|
1696
|
+
return {"symbols": _get_available_symbols(bar_period)}
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
@app.get("/api/secmaster/symbols_coverage")
|
|
1700
|
+
async def get_symbols_coverage(bar_period: str):
|
|
1701
|
+
return {"symbols": _get_symbols_with_coverage(bar_period)}
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
@app.get("/api/secmaster/bar_periods")
|
|
1705
|
+
async def get_bar_periods():
|
|
1706
|
+
return {"bar_periods": _get_available_bar_periods()}
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
@app.get("/api/presets")
|
|
1710
|
+
async def list_presets():
|
|
1711
|
+
db_path = _get_secmaster_path()
|
|
1712
|
+
if not os.path.exists(db_path):
|
|
1713
|
+
return {"presets": []}
|
|
1714
|
+
conn = sqlite3.connect(db_path)
|
|
1715
|
+
cursor = conn.cursor()
|
|
1716
|
+
cursor.execute("SELECT name FROM symbol_presets ORDER BY name")
|
|
1717
|
+
presets = [row[0] for row in cursor.fetchall()]
|
|
1718
|
+
conn.close()
|
|
1719
|
+
return {"presets": presets}
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
@app.get("/api/presets/{name}")
|
|
1723
|
+
async def get_preset(name: str):
|
|
1724
|
+
import json
|
|
1725
|
+
|
|
1726
|
+
db_path = _get_secmaster_path()
|
|
1727
|
+
if not os.path.exists(db_path):
|
|
1728
|
+
return {"error": "Preset not found"}
|
|
1729
|
+
conn = sqlite3.connect(db_path)
|
|
1730
|
+
cursor = conn.cursor()
|
|
1731
|
+
cursor.execute("SELECT symbols FROM symbol_presets WHERE name = ?", (name,))
|
|
1732
|
+
row = cursor.fetchone()
|
|
1733
|
+
conn.close()
|
|
1734
|
+
if row is None:
|
|
1735
|
+
return {"error": "Preset not found"}
|
|
1736
|
+
return {"name": name, "symbols": json.loads(row[0])}
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
class PresetRequest(BaseModel):
|
|
1740
|
+
name: str
|
|
1741
|
+
symbols: list[str]
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
@app.post("/api/presets")
|
|
1745
|
+
async def create_preset(request: PresetRequest):
|
|
1746
|
+
import json
|
|
1747
|
+
|
|
1748
|
+
db_path = _get_secmaster_path()
|
|
1749
|
+
conn = sqlite3.connect(db_path)
|
|
1750
|
+
cursor = conn.cursor()
|
|
1751
|
+
cursor.execute(
|
|
1752
|
+
"INSERT INTO symbol_presets (name, symbols) VALUES (?, ?)",
|
|
1753
|
+
(request.name, json.dumps(request.symbols)),
|
|
1754
|
+
)
|
|
1755
|
+
conn.commit()
|
|
1756
|
+
conn.close()
|
|
1757
|
+
return {"status": "created", "name": request.name}
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
@app.put("/api/presets/{name}")
|
|
1761
|
+
async def update_preset(name: str, request: PresetRequest):
|
|
1762
|
+
import json
|
|
1763
|
+
|
|
1764
|
+
db_path = _get_secmaster_path()
|
|
1765
|
+
conn = sqlite3.connect(db_path)
|
|
1766
|
+
cursor = conn.cursor()
|
|
1767
|
+
cursor.execute(
|
|
1768
|
+
"UPDATE symbol_presets SET symbols = ? WHERE name = ?",
|
|
1769
|
+
(json.dumps(request.symbols), name),
|
|
1770
|
+
)
|
|
1771
|
+
conn.commit()
|
|
1772
|
+
conn.close()
|
|
1773
|
+
return {"status": "updated", "name": name}
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
@app.delete("/api/presets/{name}")
|
|
1777
|
+
async def delete_preset(name: str):
|
|
1778
|
+
db_path = _get_secmaster_path()
|
|
1779
|
+
conn = sqlite3.connect(db_path)
|
|
1780
|
+
cursor = conn.cursor()
|
|
1781
|
+
cursor.execute("DELETE FROM symbol_presets WHERE name = ?", (name,))
|
|
1782
|
+
conn.commit()
|
|
1783
|
+
conn.close()
|
|
1784
|
+
return {"status": "deleted", "name": name}
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
class BacktestRequest(BaseModel):
|
|
1788
|
+
strategy: str
|
|
1789
|
+
strategy_params: dict
|
|
1790
|
+
symbols: list[str]
|
|
1791
|
+
start_date: str | None = None
|
|
1792
|
+
end_date: str | None = None
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _run_backtest(request: BacktestRequest, run_id: str) -> None:
|
|
1796
|
+
import pandas as pd
|
|
1797
|
+
|
|
1798
|
+
try:
|
|
1799
|
+
_running_jobs[run_id] = "running"
|
|
1800
|
+
|
|
1801
|
+
strategy_cls = registry.get_strategies().get(request.strategy)
|
|
1802
|
+
if not strategy_cls:
|
|
1803
|
+
_running_jobs[run_id] = "error: invalid strategy"
|
|
1804
|
+
return
|
|
1805
|
+
|
|
1806
|
+
strategy_params = _deserialize_params(
|
|
1807
|
+
request.strategy_params, getattr(strategy_cls, "parameters", {})
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
updated_parameters = {}
|
|
1811
|
+
for name, spec in strategy_cls.parameters.items():
|
|
1812
|
+
if name in strategy_params:
|
|
1813
|
+
updated_parameters[name] = type(spec)(
|
|
1814
|
+
default=strategy_params[name],
|
|
1815
|
+
**{k: v for k, v in spec.__dict__.items() if k != "default"},
|
|
1816
|
+
)
|
|
1817
|
+
else:
|
|
1818
|
+
updated_parameters[name] = spec
|
|
1819
|
+
|
|
1820
|
+
configured_strategy = type(
|
|
1821
|
+
f"Configured{request.strategy}",
|
|
1822
|
+
(strategy_cls,),
|
|
1823
|
+
{"symbols": request.symbols, "parameters": updated_parameters},
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
datafeed_attrs = {}
|
|
1827
|
+
if request.start_date:
|
|
1828
|
+
datafeed_attrs["start_ts"] = int(
|
|
1829
|
+
pd.Timestamp(request.start_date, tz="UTC").value
|
|
1830
|
+
)
|
|
1831
|
+
if request.end_date:
|
|
1832
|
+
end_dt = (
|
|
1833
|
+
pd.Timestamp(request.end_date, tz="UTC")
|
|
1834
|
+
+ pd.Timedelta(days=1)
|
|
1835
|
+
- pd.Timedelta(1, unit="ns")
|
|
1836
|
+
)
|
|
1837
|
+
datafeed_attrs["end_ts"] = int(end_dt.value)
|
|
1838
|
+
|
|
1839
|
+
configured_datafeed = type(
|
|
1840
|
+
"ConfiguredSimulatedDatafeed",
|
|
1841
|
+
(SimulatedDatafeed,),
|
|
1842
|
+
datafeed_attrs,
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
configured_orchestrator = type(
|
|
1846
|
+
"ConfiguredOrchestrator",
|
|
1847
|
+
(Orchestrator,),
|
|
1848
|
+
{"mode": "backtest"},
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
orchestrator = configured_orchestrator(
|
|
1852
|
+
strategies=[configured_strategy],
|
|
1853
|
+
broker=SimulatedBroker,
|
|
1854
|
+
datafeed=configured_datafeed,
|
|
1855
|
+
)
|
|
1856
|
+
|
|
1857
|
+
orchestrator.run()
|
|
1858
|
+
_running_jobs[run_id] = "completed"
|
|
1859
|
+
except Exception as e:
|
|
1860
|
+
_running_jobs[run_id] = f"error: {e}"
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _deserialize_params(params: dict, param_specs: dict) -> dict:
|
|
1864
|
+
result = {}
|
|
1865
|
+
for name, value in params.items():
|
|
1866
|
+
spec = param_specs.get(name)
|
|
1867
|
+
if spec is None:
|
|
1868
|
+
result[name] = value
|
|
1869
|
+
continue
|
|
1870
|
+
if isinstance(spec.default, enum.Enum):
|
|
1871
|
+
enum_cls = type(spec.default)
|
|
1872
|
+
result[name] = enum_cls[value]
|
|
1873
|
+
else:
|
|
1874
|
+
result[name] = value
|
|
1875
|
+
return result
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
@app.post("/api/backtest/run")
|
|
1879
|
+
async def run_backtest(request: BacktestRequest, background_tasks: BackgroundTasks):
|
|
1880
|
+
import uuid
|
|
1881
|
+
|
|
1882
|
+
run_id = str(uuid.uuid4())[:8]
|
|
1883
|
+
background_tasks.add_task(_run_backtest, request, run_id)
|
|
1884
|
+
return {"run_id": run_id, "status": "started"}
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
@app.get("/api/backtest/status/{run_id}")
|
|
1888
|
+
async def backtest_status(run_id: str):
|
|
1889
|
+
status = _running_jobs.get(run_id, "not found")
|
|
1890
|
+
return {"run_id": run_id, "status": status}
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
BACKTEST_FORM_STYLE = """
|
|
1894
|
+
.form-group { margin-bottom: 16px; }
|
|
1895
|
+
.form-group label { display: block; margin-bottom: 6px; font-size: 14px; color: #8b949e; }
|
|
1896
|
+
.form-group select, .form-group input {
|
|
1897
|
+
width: 100%; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d;
|
|
1898
|
+
border-radius: 6px; color: #e6edf3; font-size: 14px;
|
|
1899
|
+
}
|
|
1900
|
+
.form-group select:focus, .form-group input:focus { outline: none; border-color: #58a6ff; }
|
|
1901
|
+
.params-container { margin-top: 12px; padding: 12px; background: #0d1117; border-radius: 6px; }
|
|
1902
|
+
.param-row { display: flex; gap: 12px; margin-bottom: 8px; align-items: center; }
|
|
1903
|
+
.param-row label { min-width: 120px; font-size: 13px; }
|
|
1904
|
+
.param-row input, .param-row select { flex: 1; }
|
|
1905
|
+
.btn { padding: 10px 20px; background: #238636; border: none; border-radius: 6px;
|
|
1906
|
+
color: #fff; font-size: 14px; cursor: pointer; }
|
|
1907
|
+
.btn:hover { background: #2ea043; }
|
|
1908
|
+
.btn:disabled { background: #30363d; cursor: not-allowed; }
|
|
1909
|
+
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
|
1910
|
+
.btn-secondary { background: #30363d; }
|
|
1911
|
+
.btn-secondary:hover { background: #484f58; }
|
|
1912
|
+
.btn-danger { background: #da3633; }
|
|
1913
|
+
.btn-danger:hover { background: #f85149; }
|
|
1914
|
+
#status { margin-top: 16px; padding: 12px; border-radius: 6px; display: none; }
|
|
1915
|
+
.status-running { background: #9e6a03; }
|
|
1916
|
+
.status-completed { background: #238636; }
|
|
1917
|
+
.status-error { background: #da3633; }
|
|
1918
|
+
.date-row { display: flex; gap: 12px; }
|
|
1919
|
+
.date-row .form-group { flex: 1; margin-bottom: 0; }
|
|
1920
|
+
.symbol-section { background: #0d1117; border-radius: 6px; padding: 12px; }
|
|
1921
|
+
.preset-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
|
1922
|
+
.preset-row select { flex: 1; }
|
|
1923
|
+
.preset-row input { flex: 1; }
|
|
1924
|
+
.search-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
|
1925
|
+
.search-row input { flex: 1; }
|
|
1926
|
+
.search-results { max-height: 150px; overflow-y: auto; border: 1px solid #30363d; border-radius: 4px; margin-bottom: 12px; }
|
|
1927
|
+
.search-results:empty { display: none; }
|
|
1928
|
+
.search-result { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid #21262d; }
|
|
1929
|
+
.search-result:last-child { border-bottom: none; }
|
|
1930
|
+
.search-result:hover { background: #161b22; }
|
|
1931
|
+
.search-result .symbol { font-family: monospace; }
|
|
1932
|
+
.selected-symbols { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; max-height: 150px; overflow-y: auto; }
|
|
1933
|
+
.selected-tag { display: inline-flex; align-items: center; gap: 4px; background: #238636; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-family: monospace; }
|
|
1934
|
+
.selected-tag .remove { cursor: pointer; opacity: 0.7; }
|
|
1935
|
+
.selected-tag .remove:hover { opacity: 1; }
|
|
1936
|
+
.selected-label { font-size: 13px; color: #8b949e; margin-bottom: 6px; }
|
|
1937
|
+
.section-header { font-size: 14px; color: #8b949e; margin-bottom: 6px; margin-top: 8px; }
|
|
1938
|
+
"""
|
|
1939
|
+
|
|
1940
|
+
BACKTEST_FORM_SCRIPT = """
|
|
1941
|
+
let strategyParams = [];
|
|
1942
|
+
let symbolCoverage = {};
|
|
1943
|
+
let allSymbols = [];
|
|
1944
|
+
let selectedSymbols = [];
|
|
1945
|
+
let presets = [];
|
|
1946
|
+
let barPeriods = [];
|
|
1947
|
+
let currentBarPeriod = '';
|
|
1948
|
+
let globalMinTs = null;
|
|
1949
|
+
let globalMaxTs = null;
|
|
1950
|
+
let globalMinDate = null;
|
|
1951
|
+
let globalMaxDate = null;
|
|
1952
|
+
|
|
1953
|
+
async function loadStrategies() {
|
|
1954
|
+
const res = await fetch('/api/strategies');
|
|
1955
|
+
const data = await res.json();
|
|
1956
|
+
const sel = document.getElementById('strategy');
|
|
1957
|
+
data.strategies.forEach(s => sel.innerHTML += `<option value="${s.class_name}">${s.display_name}</option>`);
|
|
1958
|
+
if (data.strategies.length > 0) { sel.value = data.strategies[0].class_name; loadStrategyParams(); }
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
async function loadBarPeriods() {
|
|
1962
|
+
const res = await fetch('/api/secmaster/bar_periods');
|
|
1963
|
+
const data = await res.json();
|
|
1964
|
+
barPeriods = data.bar_periods || [];
|
|
1965
|
+
const sel = document.getElementById('bar-period');
|
|
1966
|
+
sel.innerHTML = '';
|
|
1967
|
+
barPeriods.forEach(p => sel.innerHTML += `<option value="${p}">${p}</option>`);
|
|
1968
|
+
if (barPeriods.length > 0) {
|
|
1969
|
+
currentBarPeriod = barPeriods[0];
|
|
1970
|
+
sel.value = currentBarPeriod;
|
|
1971
|
+
await loadSymbolsForPeriod();
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async function onBarPeriodChange() {
|
|
1976
|
+
currentBarPeriod = document.getElementById('bar-period').value;
|
|
1977
|
+
selectedSymbols = [];
|
|
1978
|
+
renderSelectedSymbols();
|
|
1979
|
+
await loadSymbolsForPeriod();
|
|
1980
|
+
updateDateRange();
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
async function loadSymbolsForPeriod() {
|
|
1984
|
+
const res = await fetch(`/api/secmaster/symbols_coverage?bar_period=${currentBarPeriod}`);
|
|
1985
|
+
const data = await res.json();
|
|
1986
|
+
symbolCoverage = {};
|
|
1987
|
+
allSymbols = [];
|
|
1988
|
+
(data.symbols || []).forEach(s => {
|
|
1989
|
+
symbolCoverage[s.symbol] = {min_ts: s.min_ts, max_ts: s.max_ts};
|
|
1990
|
+
allSymbols.push(s.symbol);
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
async function loadPresets() {
|
|
1995
|
+
const res = await fetch('/api/presets');
|
|
1996
|
+
const data = await res.json();
|
|
1997
|
+
presets = data.presets || [];
|
|
1998
|
+
renderPresetSelect();
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function renderPresetSelect() {
|
|
2002
|
+
const sel = document.getElementById('preset-select');
|
|
2003
|
+
sel.innerHTML = '<option value="">-- Select Preset --</option>';
|
|
2004
|
+
presets.forEach(p => sel.innerHTML += `<option value="${p}">${p}</option>`);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
async function loadPreset() {
|
|
2008
|
+
const name = document.getElementById('preset-select').value;
|
|
2009
|
+
if (!name) return;
|
|
2010
|
+
const res = await fetch(`/api/presets/${encodeURIComponent(name)}`);
|
|
2011
|
+
const data = await res.json();
|
|
2012
|
+
if (data.symbols) {
|
|
2013
|
+
selectedSymbols = data.symbols.filter(s => allSymbols.includes(s));
|
|
2014
|
+
renderSelectedSymbols();
|
|
2015
|
+
updateDateRange();
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
async function savePreset() {
|
|
2020
|
+
const nameInput = document.getElementById('preset-name');
|
|
2021
|
+
const name = nameInput.value.trim();
|
|
2022
|
+
if (!name) { alert('Enter a preset name'); return; }
|
|
2023
|
+
if (selectedSymbols.length === 0) { alert('Select at least one symbol'); return; }
|
|
2024
|
+
const exists = presets.includes(name);
|
|
2025
|
+
const method = exists ? 'PUT' : 'POST';
|
|
2026
|
+
const url = exists ? `/api/presets/${encodeURIComponent(name)}` : '/api/presets';
|
|
2027
|
+
await fetch(url, {
|
|
2028
|
+
method, headers: {'Content-Type': 'application/json'},
|
|
2029
|
+
body: JSON.stringify({name, symbols: selectedSymbols})
|
|
2030
|
+
});
|
|
2031
|
+
nameInput.value = '';
|
|
2032
|
+
await loadPresets();
|
|
2033
|
+
document.getElementById('preset-select').value = name;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
async function deletePreset() {
|
|
2037
|
+
const name = document.getElementById('preset-select').value;
|
|
2038
|
+
if (!name) { alert('Select a preset to delete'); return; }
|
|
2039
|
+
if (!confirm(`Delete preset "${name}"?`)) return;
|
|
2040
|
+
await fetch(`/api/presets/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
|
2041
|
+
await loadPresets();
|
|
2042
|
+
selectedSymbols = [];
|
|
2043
|
+
renderSelectedSymbols();
|
|
2044
|
+
updateDateRange();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function searchSymbols() {
|
|
2048
|
+
const query = document.getElementById('symbol-search').value.toUpperCase();
|
|
2049
|
+
const container = document.getElementById('search-results');
|
|
2050
|
+
if (query.length < 1) { container.innerHTML = ''; return; }
|
|
2051
|
+
const matches = allSymbols.filter(s => s.toUpperCase().includes(query) && !selectedSymbols.includes(s)).slice(0, 20);
|
|
2052
|
+
container.innerHTML = matches.map(s => `<div class="search-result"><span class="symbol">${s}</span><button class="btn btn-sm" onclick="addSymbol('${s}')">+</button></div>`).join('');
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function addSymbol(symbol) {
|
|
2056
|
+
if (!selectedSymbols.includes(symbol)) {
|
|
2057
|
+
selectedSymbols.push(symbol);
|
|
2058
|
+
selectedSymbols.sort();
|
|
2059
|
+
renderSelectedSymbols();
|
|
2060
|
+
searchSymbols();
|
|
2061
|
+
updateDateRange();
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function removeSymbol(symbol) {
|
|
2066
|
+
selectedSymbols = selectedSymbols.filter(s => s !== symbol);
|
|
2067
|
+
renderSelectedSymbols();
|
|
2068
|
+
searchSymbols();
|
|
2069
|
+
updateDateRange();
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function renderSelectedSymbols() {
|
|
2073
|
+
const container = document.getElementById('selected-symbols');
|
|
2074
|
+
const label = document.getElementById('selected-label');
|
|
2075
|
+
label.textContent = `Selected (${selectedSymbols.length}):`;
|
|
2076
|
+
container.innerHTML = selectedSymbols.map(s => `<span class="selected-tag">${s}<span class="remove" onclick="removeSymbol('${s}')">×</span></span>`).join('');
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function updateDateRange() {
|
|
2080
|
+
if (selectedSymbols.length === 0) {
|
|
2081
|
+
globalMinTs = null;
|
|
2082
|
+
globalMaxTs = null;
|
|
2083
|
+
document.getElementById('start-date').value = '';
|
|
2084
|
+
document.getElementById('end-date').value = '';
|
|
2085
|
+
document.getElementById('range-slider-container').style.display = 'none';
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
let minTs = Infinity, maxTs = -Infinity;
|
|
2089
|
+
selectedSymbols.forEach(s => {
|
|
2090
|
+
const cov = symbolCoverage[s];
|
|
2091
|
+
if (cov) {
|
|
2092
|
+
if (cov.min_ts < minTs) minTs = cov.min_ts;
|
|
2093
|
+
if (cov.max_ts > maxTs) maxTs = cov.max_ts;
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
if (minTs === Infinity) return;
|
|
2097
|
+
globalMinTs = minTs;
|
|
2098
|
+
globalMaxTs = maxTs;
|
|
2099
|
+
globalMinDate = tsToDate(minTs);
|
|
2100
|
+
globalMaxDate = tsToDate(maxTs);
|
|
2101
|
+
const startInput = document.getElementById('start-date');
|
|
2102
|
+
const endInput = document.getElementById('end-date');
|
|
2103
|
+
startInput.min = globalMinDate;
|
|
2104
|
+
startInput.max = globalMaxDate;
|
|
2105
|
+
startInput.value = globalMinDate;
|
|
2106
|
+
endInput.min = globalMinDate;
|
|
2107
|
+
endInput.max = globalMaxDate;
|
|
2108
|
+
endInput.value = globalMaxDate;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function tsToDate(ts) {
|
|
2112
|
+
return new Date(ts / 1000000).toISOString().split('T')[0];
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
function clampDate(inputId) {
|
|
2116
|
+
if (!globalMinDate || !globalMaxDate) return;
|
|
2117
|
+
const input = document.getElementById(inputId);
|
|
2118
|
+
if (input.value < globalMinDate) input.value = globalMinDate;
|
|
2119
|
+
if (input.value > globalMaxDate) input.value = globalMaxDate;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
async function loadStrategyParams() {
|
|
2123
|
+
const name = document.getElementById('strategy').value;
|
|
2124
|
+
const res = await fetch(`/api/strategies/${name}`);
|
|
2125
|
+
const data = await res.json();
|
|
2126
|
+
strategyParams = (data.parameters || []).filter(p => p.name !== 'bar_period');
|
|
2127
|
+
renderParams('strategy-params', strategyParams, 'sp_');
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function formatParamName(name) {
|
|
2131
|
+
return name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function renderParams(containerId, params, prefix) {
|
|
2135
|
+
const container = document.getElementById(containerId);
|
|
2136
|
+
container.innerHTML = '';
|
|
2137
|
+
params.forEach(p => {
|
|
2138
|
+
const row = document.createElement('div');
|
|
2139
|
+
row.className = 'param-row';
|
|
2140
|
+
let input;
|
|
2141
|
+
if (p.choices) {
|
|
2142
|
+
input = `<select id="${prefix}${p.name}">` +
|
|
2143
|
+
p.choices.map(c => `<option value="${c}" ${c===p.default?'selected':''}>${c}</option>`).join('') +
|
|
2144
|
+
'</select>';
|
|
2145
|
+
} else if (p.type === 'bool') {
|
|
2146
|
+
input = `<select id="${prefix}${p.name}"><option value="true" ${p.default?'selected':''}>true</option><option value="false" ${!p.default?'selected':''}>false</option></select>`;
|
|
2147
|
+
} else {
|
|
2148
|
+
const attrs = [];
|
|
2149
|
+
if (p.min !== undefined) attrs.push(`min="${p.min}"`);
|
|
2150
|
+
if (p.max !== undefined) attrs.push(`max="${p.max}"`);
|
|
2151
|
+
if (p.step !== undefined) attrs.push(`step="${p.step}"`);
|
|
2152
|
+
input = `<input type="${p.type==='int'||p.type==='float'?'number':'text'}" id="${prefix}${p.name}" value="${p.default}" ${attrs.join(' ')}>`;
|
|
2153
|
+
}
|
|
2154
|
+
row.innerHTML = `<label>${formatParamName(p.name)}</label>${input}`;
|
|
2155
|
+
container.appendChild(row);
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function collectParams(params, prefix) {
|
|
2160
|
+
const result = {};
|
|
2161
|
+
params.forEach(p => {
|
|
2162
|
+
const el = document.getElementById(prefix + p.name);
|
|
2163
|
+
let val = el.value;
|
|
2164
|
+
if (p.type === 'int') val = parseInt(val);
|
|
2165
|
+
else if (p.type === 'float') val = parseFloat(val);
|
|
2166
|
+
else if (p.type === 'bool') val = val === 'true';
|
|
2167
|
+
result[p.name] = val;
|
|
2168
|
+
});
|
|
2169
|
+
return result;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async function runBacktest() {
|
|
2173
|
+
const btn = document.getElementById('run-btn');
|
|
2174
|
+
const status = document.getElementById('status');
|
|
2175
|
+
if (selectedSymbols.length === 0) {
|
|
2176
|
+
alert('Please select at least one symbol');
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
btn.disabled = true;
|
|
2180
|
+
status.style.display = 'block';
|
|
2181
|
+
status.className = 'status-running';
|
|
2182
|
+
status.textContent = 'Starting backtest...';
|
|
2183
|
+
|
|
2184
|
+
const params = collectParams(strategyParams, 'sp_');
|
|
2185
|
+
params.bar_period = currentBarPeriod;
|
|
2186
|
+
const payload = {
|
|
2187
|
+
strategy: document.getElementById('strategy').value,
|
|
2188
|
+
strategy_params: params,
|
|
2189
|
+
symbols: selectedSymbols,
|
|
2190
|
+
start_date: document.getElementById('start-date').value || null,
|
|
2191
|
+
end_date: document.getElementById('end-date').value || null
|
|
2192
|
+
};
|
|
2193
|
+
|
|
2194
|
+
const res = await fetch('/api/backtest/run', {
|
|
2195
|
+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
|
2196
|
+
});
|
|
2197
|
+
const data = await res.json();
|
|
2198
|
+
const runId = data.run_id;
|
|
2199
|
+
status.textContent = `Backtest ${runId} running...`;
|
|
2200
|
+
|
|
2201
|
+
const poll = setInterval(async () => {
|
|
2202
|
+
const r = await fetch(`/api/backtest/status/${runId}`);
|
|
2203
|
+
const d = await r.json();
|
|
2204
|
+
if (d.status === 'completed') {
|
|
2205
|
+
clearInterval(poll);
|
|
2206
|
+
status.className = 'status-completed';
|
|
2207
|
+
status.textContent = `Backtest ${runId} completed!`;
|
|
2208
|
+
btn.disabled = false;
|
|
2209
|
+
} else if (d.status.startsWith('error')) {
|
|
2210
|
+
clearInterval(poll);
|
|
2211
|
+
status.className = 'status-error';
|
|
2212
|
+
status.textContent = `Backtest ${runId}: ${d.status}`;
|
|
2213
|
+
btn.disabled = false;
|
|
2214
|
+
}
|
|
2215
|
+
}, 1000);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2219
|
+
loadStrategies();
|
|
2220
|
+
loadBarPeriods();
|
|
2221
|
+
loadPresets();
|
|
2222
|
+
});
|
|
2223
|
+
"""
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
@app.get("/backtest", response_class=HTMLResponse)
|
|
2227
|
+
async def backtest_form():
|
|
2228
|
+
sidebar = SIDEBAR_HTML.format(
|
|
2229
|
+
runs_active="",
|
|
2230
|
+
backtest_active="active",
|
|
2231
|
+
secmaster_active="",
|
|
2232
|
+
pipeline_active="",
|
|
2233
|
+
pipeline_open="",
|
|
2234
|
+
pipeline_sub1_active="",
|
|
2235
|
+
pipeline_sub2_active="",
|
|
2236
|
+
)
|
|
2237
|
+
return f"""
|
|
2238
|
+
<!DOCTYPE html>
|
|
2239
|
+
<html>
|
|
2240
|
+
<head>
|
|
2241
|
+
<title>Backtest - OneSecondTrader</title>
|
|
2242
|
+
<style>{BASE_STYLE}{BACKTEST_FORM_STYLE}</style>
|
|
2243
|
+
</head>
|
|
2244
|
+
<body>
|
|
2245
|
+
{sidebar}
|
|
2246
|
+
<main class="main-content">
|
|
2247
|
+
<div class="container">
|
|
2248
|
+
<div class="card">
|
|
2249
|
+
<h2>Run Backtest</h2>
|
|
2250
|
+
<div class="form-group">
|
|
2251
|
+
<label>Strategy</label>
|
|
2252
|
+
<select id="strategy" onchange="loadStrategyParams()"></select>
|
|
2253
|
+
<div class="section-header">Parameters</div>
|
|
2254
|
+
<div id="strategy-params" class="params-container"></div>
|
|
2255
|
+
</div>
|
|
2256
|
+
<div class="form-group">
|
|
2257
|
+
<label>Bar Period</label>
|
|
2258
|
+
<select id="bar-period" onchange="onBarPeriodChange()"></select>
|
|
2259
|
+
</div>
|
|
2260
|
+
<div class="form-group">
|
|
2261
|
+
<label>Symbols</label>
|
|
2262
|
+
<div class="symbol-section">
|
|
2263
|
+
<div class="preset-row">
|
|
2264
|
+
<select id="preset-select" onchange="loadPreset()"></select>
|
|
2265
|
+
<input type="text" id="preset-name" placeholder="New preset name...">
|
|
2266
|
+
<button class="btn btn-sm btn-secondary" onclick="savePreset()">Save</button>
|
|
2267
|
+
<button class="btn btn-sm btn-danger" onclick="deletePreset()">Delete</button>
|
|
2268
|
+
</div>
|
|
2269
|
+
<div class="search-row">
|
|
2270
|
+
<input type="text" id="symbol-search" placeholder="Search symbols..." oninput="searchSymbols()">
|
|
2271
|
+
</div>
|
|
2272
|
+
<div id="search-results" class="search-results"></div>
|
|
2273
|
+
<div id="selected-label" class="selected-label">Selected (0):</div>
|
|
2274
|
+
<div id="selected-symbols" class="selected-symbols"></div>
|
|
2275
|
+
</div>
|
|
2276
|
+
</div>
|
|
2277
|
+
<div class="date-row">
|
|
2278
|
+
<div class="form-group">
|
|
2279
|
+
<label>Start Date</label>
|
|
2280
|
+
<input type="date" id="start-date" onchange="clampDate('start-date')">
|
|
2281
|
+
</div>
|
|
2282
|
+
<div class="form-group">
|
|
2283
|
+
<label>End Date</label>
|
|
2284
|
+
<input type="date" id="end-date" onchange="clampDate('end-date')">
|
|
2285
|
+
</div>
|
|
2286
|
+
</div>
|
|
2287
|
+
<div class="form-group" style="margin-top: 16px;">
|
|
2288
|
+
<button id="run-btn" class="btn" onclick="runBacktest()">Run Backtest</button>
|
|
2289
|
+
</div>
|
|
2290
|
+
<div id="status"></div>
|
|
2291
|
+
</div>
|
|
2292
|
+
</div>
|
|
2293
|
+
</main>
|
|
2294
|
+
<script>{BACKTEST_FORM_SCRIPT}</script>
|
|
2295
|
+
</body>
|
|
2296
|
+
</html>
|
|
2297
|
+
"""
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def _get_secmaster_summary() -> dict:
|
|
2301
|
+
db_path = _get_secmaster_path()
|
|
2302
|
+
if not os.path.exists(db_path):
|
|
2303
|
+
return {"exists": False}
|
|
2304
|
+
|
|
2305
|
+
conn = sqlite3.connect(db_path)
|
|
2306
|
+
cursor = conn.cursor()
|
|
2307
|
+
|
|
2308
|
+
cursor.execute("SELECT key, value FROM meta")
|
|
2309
|
+
meta = {row[0]: row[1] for row in cursor.fetchall()}
|
|
2310
|
+
conn.close()
|
|
2311
|
+
|
|
2312
|
+
if not meta:
|
|
2313
|
+
return {"exists": True, "db_path": db_path, "needs_reindex": True}
|
|
2314
|
+
|
|
2315
|
+
rtypes_str = meta.get("ohlcv_schemas", "")
|
|
2316
|
+
rtypes = [RTYPE_TO_BAR_PERIOD.get(int(r), r) for r in rtypes_str.split(",") if r]
|
|
2317
|
+
|
|
2318
|
+
db_size_bytes = os.path.getsize(db_path)
|
|
2319
|
+
|
|
2320
|
+
return {
|
|
2321
|
+
"exists": True,
|
|
2322
|
+
"db_path": db_path,
|
|
2323
|
+
"db_size_mb": round(db_size_bytes / (1024 * 1024), 1),
|
|
2324
|
+
"symbol_count": int(meta.get("symbol_count", 0)),
|
|
2325
|
+
"ohlcv_record_count": int(meta.get("ohlcv_record_count", 0)),
|
|
2326
|
+
"min_ts": int(meta.get("ohlcv_min_ts", 0)),
|
|
2327
|
+
"max_ts": int(meta.get("ohlcv_max_ts", 0)),
|
|
2328
|
+
"schemas": rtypes,
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
|
|
2332
|
+
def _search_symbol(query: str) -> list[dict]:
|
|
2333
|
+
db_path = _get_secmaster_path()
|
|
2334
|
+
if not os.path.exists(db_path):
|
|
2335
|
+
return []
|
|
2336
|
+
|
|
2337
|
+
conn = sqlite3.connect(db_path)
|
|
2338
|
+
cursor = conn.cursor()
|
|
2339
|
+
|
|
2340
|
+
cursor.execute(
|
|
2341
|
+
"""
|
|
2342
|
+
SELECT symbol, rtype, min_ts, max_ts, record_count
|
|
2343
|
+
FROM symbol_coverage
|
|
2344
|
+
WHERE symbol LIKE ?
|
|
2345
|
+
ORDER BY symbol
|
|
2346
|
+
LIMIT 100
|
|
2347
|
+
""",
|
|
2348
|
+
(f"%{query}%",),
|
|
2349
|
+
)
|
|
2350
|
+
|
|
2351
|
+
results = []
|
|
2352
|
+
for row in cursor.fetchall():
|
|
2353
|
+
results.append(
|
|
2354
|
+
{
|
|
2355
|
+
"symbol": row[0],
|
|
2356
|
+
"min_ts": row[2],
|
|
2357
|
+
"max_ts": row[3],
|
|
2358
|
+
"record_count": row[4],
|
|
2359
|
+
"schema": (
|
|
2360
|
+
RTYPE_TO_BAR_PERIOD.get(row[1], str(row[1])) if row[1] else None
|
|
2361
|
+
),
|
|
2362
|
+
}
|
|
2363
|
+
)
|
|
2364
|
+
|
|
2365
|
+
conn.close()
|
|
2366
|
+
return results
|
|
2367
|
+
|
|
2368
|
+
|
|
2369
|
+
@app.get("/api/secmaster/summary")
|
|
2370
|
+
async def get_secmaster_summary():
|
|
2371
|
+
return _get_secmaster_summary()
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
@app.get("/api/secmaster/search")
|
|
2375
|
+
async def search_secmaster(q: str = ""):
|
|
2376
|
+
if len(q) < 1:
|
|
2377
|
+
return {"results": []}
|
|
2378
|
+
return {"results": _search_symbol(q)}
|
|
2379
|
+
|
|
2380
|
+
|
|
2381
|
+
SECMASTER_STYLE = """
|
|
2382
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
2383
|
+
.stat-card { background: #0d1117; border-radius: 8px; padding: 20px; }
|
|
2384
|
+
.stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
2385
|
+
.stat-card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
|
|
2386
|
+
.stat-card .sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
|
|
2387
|
+
.asset-table { width: 100%; border-collapse: collapse; }
|
|
2388
|
+
.asset-table th, .asset-table td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
|
2389
|
+
.asset-table th { font-size: 12px; color: #8b949e; text-transform: uppercase; font-weight: 500; }
|
|
2390
|
+
.asset-table tr:hover { background: #21262d; }
|
|
2391
|
+
.search-box { width: 100%; padding: 12px 16px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 14px; margin-bottom: 16px; }
|
|
2392
|
+
.search-box:focus { outline: none; border-color: #58a6ff; }
|
|
2393
|
+
.search-results { max-height: 400px; overflow-y: auto; }
|
|
2394
|
+
.no-db { text-align: center; padding: 48px; color: #8b949e; }
|
|
2395
|
+
.no-db code { background: #21262d; padding: 2px 6px; border-radius: 4px; }
|
|
2396
|
+
.loading { display: flex; align-items: center; gap: 12px; color: #8b949e; padding: 24px; }
|
|
2397
|
+
.spinner { width: 20px; height: 20px; border: 2px solid #30363d; border-top-color: #58a6ff; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
2398
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
2399
|
+
"""
|
|
2400
|
+
|
|
2401
|
+
SECMASTER_SCRIPT = """
|
|
2402
|
+
function showLoading(elementId, message) {
|
|
2403
|
+
document.getElementById(elementId).innerHTML = `<div class="loading"><div class="spinner"></div><span>${message}</span></div>`;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
async function loadSummary() {
|
|
2407
|
+
showLoading('content', 'Loading database summary...');
|
|
2408
|
+
const res = await fetch('/api/secmaster/summary');
|
|
2409
|
+
const data = await res.json();
|
|
2410
|
+
|
|
2411
|
+
if (!data.exists) {
|
|
2412
|
+
document.getElementById('content').innerHTML = `
|
|
2413
|
+
<div class="no-db">
|
|
2414
|
+
<p>No securities master database found.</p>
|
|
2415
|
+
<p style="margin-top: 8px;">Set <code>SECMASTER_DB_PATH</code> environment variable or place <code>secmaster.db</code> in the working directory.</p>
|
|
2416
|
+
</div>
|
|
2417
|
+
`;
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
if (data.needs_reindex) {
|
|
2422
|
+
document.getElementById('content').innerHTML = `
|
|
2423
|
+
<div class="no-db">
|
|
2424
|
+
<p>Database metadata not found.</p>
|
|
2425
|
+
<p style="margin-top: 8px;">Run <code>from onesecondtrader.secmaster.utils import update_meta; update_meta(path)</code> to rebuild stats.</p>
|
|
2426
|
+
</div>
|
|
2427
|
+
`;
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
const minDate = new Date(data.min_ts / 1000000).toISOString().split('T')[0];
|
|
2432
|
+
const maxDate = new Date(data.max_ts / 1000000).toISOString().split('T')[0];
|
|
2433
|
+
|
|
2434
|
+
document.getElementById('content').innerHTML = `
|
|
2435
|
+
<div class="stats-grid">
|
|
2436
|
+
<div class="stat-card">
|
|
2437
|
+
<div class="label">Symbols</div>
|
|
2438
|
+
<div class="value">${data.symbol_count.toLocaleString()}</div>
|
|
2439
|
+
</div>
|
|
2440
|
+
<div class="stat-card">
|
|
2441
|
+
<div class="label">OHLCV Records</div>
|
|
2442
|
+
<div class="value">${data.ohlcv_record_count.toLocaleString()}</div>
|
|
2443
|
+
</div>
|
|
2444
|
+
<div class="stat-card">
|
|
2445
|
+
<div class="label">Date Range</div>
|
|
2446
|
+
<div class="value" style="font-size: 18px;">${minDate}</div>
|
|
2447
|
+
<div class="sub">to ${maxDate}</div>
|
|
2448
|
+
</div>
|
|
2449
|
+
<div class="stat-card">
|
|
2450
|
+
<div class="label">Schemas</div>
|
|
2451
|
+
<div class="value" style="font-size: 18px;">${data.schemas.join(', ')}</div>
|
|
2452
|
+
</div>
|
|
2453
|
+
<div class="stat-card">
|
|
2454
|
+
<div class="label">Database Size</div>
|
|
2455
|
+
<div class="value">${data.db_size_mb} MB</div>
|
|
2456
|
+
<div class="sub">${data.db_path}</div>
|
|
2457
|
+
</div>
|
|
2458
|
+
</div>
|
|
2459
|
+
<div class="card">
|
|
2460
|
+
<h2>Symbol Search</h2>
|
|
2461
|
+
<input type="text" class="search-box" id="search-input" placeholder="Search symbols..." oninput="searchSymbols()">
|
|
2462
|
+
<div id="search-results" class="search-results"></div>
|
|
2463
|
+
</div>
|
|
2464
|
+
`;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
let searchTimeout;
|
|
2468
|
+
async function searchSymbols() {
|
|
2469
|
+
const q = document.getElementById('search-input').value;
|
|
2470
|
+
clearTimeout(searchTimeout);
|
|
2471
|
+
if (q.length < 1) {
|
|
2472
|
+
document.getElementById('search-results').innerHTML = '';
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
showLoading('search-results', 'Searching...');
|
|
2476
|
+
searchTimeout = setTimeout(async () => {
|
|
2477
|
+
const res = await fetch('/api/secmaster/search?q=' + encodeURIComponent(q));
|
|
2478
|
+
const data = await res.json();
|
|
2479
|
+
if (data.results.length === 0) {
|
|
2480
|
+
document.getElementById('search-results').innerHTML = '<p style="color: #8b949e;">No results found.</p>';
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
let rows = data.results.map(r => {
|
|
2484
|
+
const minDate = r.min_ts ? new Date(r.min_ts / 1000000).toISOString().split('T')[0] : '-';
|
|
2485
|
+
const maxDate = r.max_ts ? new Date(r.max_ts / 1000000).toISOString().split('T')[0] : '-';
|
|
2486
|
+
return `<tr>
|
|
2487
|
+
<td>${r.symbol}</td>
|
|
2488
|
+
<td>${r.schema || '-'}</td>
|
|
2489
|
+
<td>${minDate} → ${maxDate}</td>
|
|
2490
|
+
<td>${r.record_count ? r.record_count.toLocaleString() : '-'}</td>
|
|
2491
|
+
</tr>`;
|
|
2492
|
+
}).join('');
|
|
2493
|
+
document.getElementById('search-results').innerHTML = `
|
|
2494
|
+
<table class="asset-table">
|
|
2495
|
+
<thead><tr><th>Symbol</th><th>Schema</th><th>Date Range</th><th>Records</th></tr></thead>
|
|
2496
|
+
<tbody>${rows}</tbody>
|
|
2497
|
+
</table>
|
|
2498
|
+
`;
|
|
2499
|
+
}, 300);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
document.addEventListener('DOMContentLoaded', loadSummary);
|
|
2503
|
+
"""
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
@app.get("/secmaster", response_class=HTMLResponse)
|
|
2507
|
+
async def secmaster_page():
|
|
2508
|
+
sidebar = SIDEBAR_HTML.format(
|
|
2509
|
+
runs_active="",
|
|
2510
|
+
backtest_active="",
|
|
2511
|
+
secmaster_active="active",
|
|
2512
|
+
pipeline_active="",
|
|
2513
|
+
pipeline_open="",
|
|
2514
|
+
pipeline_sub1_active="",
|
|
2515
|
+
pipeline_sub2_active="",
|
|
2516
|
+
)
|
|
2517
|
+
return f"""
|
|
2518
|
+
<!DOCTYPE html>
|
|
2519
|
+
<html>
|
|
2520
|
+
<head>
|
|
2521
|
+
<title>Securities Master - OneSecondTrader</title>
|
|
2522
|
+
<style>{BASE_STYLE}{SECMASTER_STYLE}</style>
|
|
2523
|
+
</head>
|
|
2524
|
+
<body>
|
|
2525
|
+
{sidebar}
|
|
2526
|
+
<main class="main-content">
|
|
2527
|
+
<div class="container" id="content">
|
|
2528
|
+
<p style="color: #8b949e;">Loading...</p>
|
|
2529
|
+
</div>
|
|
2530
|
+
</main>
|
|
2531
|
+
<script>{SECMASTER_SCRIPT}</script>
|
|
2532
|
+
</body>
|
|
2533
|
+
</html>
|
|
2534
|
+
"""
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
@app.get("/api/signals/{run_id}")
|
|
2538
|
+
async def get_signals_for_run(run_id: str):
|
|
2539
|
+
db_path = _get_runs_db_path()
|
|
2540
|
+
if not os.path.exists(db_path):
|
|
2541
|
+
return {"actions": {}}
|
|
2542
|
+
conn = sqlite3.connect(db_path)
|
|
2543
|
+
cursor = conn.cursor()
|
|
2544
|
+
cursor.execute(
|
|
2545
|
+
"""SELECT id, ts_event, order_id, symbol, order_type, side, quantity,
|
|
2546
|
+
limit_price, stop_price, action, signal
|
|
2547
|
+
FROM order_requests
|
|
2548
|
+
WHERE run_id = ? AND request_type = 'submission'
|
|
2549
|
+
ORDER BY ts_event""",
|
|
2550
|
+
(run_id,),
|
|
2551
|
+
)
|
|
2552
|
+
requests = cursor.fetchall()
|
|
2553
|
+
cursor.execute(
|
|
2554
|
+
"""SELECT order_id, response_type FROM order_responses WHERE run_id = ?""",
|
|
2555
|
+
(run_id,),
|
|
2556
|
+
)
|
|
2557
|
+
responses = {row[0]: row[1] for row in cursor.fetchall()}
|
|
2558
|
+
cursor.execute(
|
|
2559
|
+
"""SELECT order_id FROM fills WHERE run_id = ?""",
|
|
2560
|
+
(run_id,),
|
|
2561
|
+
)
|
|
2562
|
+
filled_orders = {row[0] for row in cursor.fetchall()}
|
|
2563
|
+
conn.close()
|
|
2564
|
+
|
|
2565
|
+
actions: dict[str, dict[str, list]] = {}
|
|
2566
|
+
for row in requests:
|
|
2567
|
+
(
|
|
2568
|
+
_,
|
|
2569
|
+
ts_event,
|
|
2570
|
+
order_id,
|
|
2571
|
+
symbol,
|
|
2572
|
+
order_type,
|
|
2573
|
+
side,
|
|
2574
|
+
quantity,
|
|
2575
|
+
limit_price,
|
|
2576
|
+
stop_price,
|
|
2577
|
+
action,
|
|
2578
|
+
signal,
|
|
2579
|
+
) = row
|
|
2580
|
+
action = action or "UNKNOWN"
|
|
2581
|
+
signal = signal or "unnamed"
|
|
2582
|
+
if action not in actions:
|
|
2583
|
+
actions[action] = {}
|
|
2584
|
+
if signal not in actions[action]:
|
|
2585
|
+
actions[action][signal] = []
|
|
2586
|
+
if order_id in filled_orders:
|
|
2587
|
+
status = "FILLED"
|
|
2588
|
+
elif order_id in responses:
|
|
2589
|
+
resp = responses[order_id]
|
|
2590
|
+
if "accepted" in resp:
|
|
2591
|
+
status = "ACCEPTED"
|
|
2592
|
+
elif "rejected" in resp:
|
|
2593
|
+
status = "REJECTED"
|
|
2594
|
+
elif resp == "expired":
|
|
2595
|
+
status = "EXPIRED"
|
|
2596
|
+
elif "cancellation" in resp:
|
|
2597
|
+
status = "CANCELLED"
|
|
2598
|
+
else:
|
|
2599
|
+
status = resp.upper()
|
|
2600
|
+
else:
|
|
2601
|
+
status = "PENDING"
|
|
2602
|
+
actions[action][signal].append(
|
|
2603
|
+
{
|
|
2604
|
+
"order_id": order_id,
|
|
2605
|
+
"ts_event": ts_event,
|
|
2606
|
+
"symbol": symbol,
|
|
2607
|
+
"order_type": order_type,
|
|
2608
|
+
"side": side or "BUY",
|
|
2609
|
+
"quantity": quantity,
|
|
2610
|
+
"limit_price": limit_price,
|
|
2611
|
+
"stop_price": stop_price,
|
|
2612
|
+
"status": status,
|
|
2613
|
+
}
|
|
2614
|
+
)
|
|
2615
|
+
action_types = [at.name for at in ActionType]
|
|
2616
|
+
return {"actions": actions, "action_types": action_types}
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
@app.get("/api/signals/{run_id}/chart/{order_id}")
|
|
2620
|
+
async def get_signal_chart(run_id: str, order_id: str):
|
|
2621
|
+
from fastapi import HTTPException
|
|
2622
|
+
|
|
2623
|
+
db_path = _get_runs_db_path()
|
|
2624
|
+
if not os.path.exists(db_path):
|
|
2625
|
+
raise HTTPException(status_code=404, detail="Database not found")
|
|
2626
|
+
|
|
2627
|
+
conn = sqlite3.connect(db_path)
|
|
2628
|
+
cursor = conn.cursor()
|
|
2629
|
+
cursor.execute(
|
|
2630
|
+
"""SELECT ts_event, symbol, order_type, side, quantity, limit_price, stop_price, action, signal
|
|
2631
|
+
FROM order_requests
|
|
2632
|
+
WHERE run_id = ? AND order_id = ? AND request_type = 'submission'""",
|
|
2633
|
+
(run_id, order_id),
|
|
2634
|
+
)
|
|
2635
|
+
req_row = cursor.fetchone()
|
|
2636
|
+
if not req_row:
|
|
2637
|
+
conn.close()
|
|
2638
|
+
raise HTTPException(status_code=404, detail="Order not found")
|
|
2639
|
+
|
|
2640
|
+
(
|
|
2641
|
+
ts_event,
|
|
2642
|
+
symbol,
|
|
2643
|
+
order_type,
|
|
2644
|
+
side,
|
|
2645
|
+
quantity,
|
|
2646
|
+
limit_price,
|
|
2647
|
+
stop_price,
|
|
2648
|
+
action,
|
|
2649
|
+
signal,
|
|
2650
|
+
) = req_row
|
|
2651
|
+
submission_time = pd.to_datetime(ts_event)
|
|
2652
|
+
|
|
2653
|
+
cursor.execute(
|
|
2654
|
+
"""SELECT ts_event, response_type FROM order_responses
|
|
2655
|
+
WHERE run_id = ? AND order_id = ?
|
|
2656
|
+
ORDER BY ts_event""",
|
|
2657
|
+
(run_id, order_id),
|
|
2658
|
+
)
|
|
2659
|
+
response_rows = cursor.fetchall()
|
|
2660
|
+
|
|
2661
|
+
cursor.execute(
|
|
2662
|
+
"""SELECT ts_event, price, quantity FROM fills
|
|
2663
|
+
WHERE run_id = ? AND order_id = ?
|
|
2664
|
+
ORDER BY ts_event""",
|
|
2665
|
+
(run_id, order_id),
|
|
2666
|
+
)
|
|
2667
|
+
fill_rows = cursor.fetchall()
|
|
2668
|
+
conn.close()
|
|
2669
|
+
|
|
2670
|
+
resolution_time = None
|
|
2671
|
+
resolution_type = None
|
|
2672
|
+
fill_price = None
|
|
2673
|
+
if fill_rows:
|
|
2674
|
+
resolution_time = pd.to_datetime(fill_rows[-1][0])
|
|
2675
|
+
resolution_type = "FILLED"
|
|
2676
|
+
fill_price = fill_rows[-1][1]
|
|
2677
|
+
elif response_rows:
|
|
2678
|
+
for ts, resp_type in response_rows:
|
|
2679
|
+
if resp_type in ("expired", "cancellation_accepted", "submission_rejected"):
|
|
2680
|
+
resolution_time = pd.to_datetime(ts)
|
|
2681
|
+
resolution_type = resp_type.upper().replace("_", " ")
|
|
2682
|
+
break
|
|
2683
|
+
|
|
2684
|
+
bars_df = _load_bars_for_chart(run_id, symbol)
|
|
2685
|
+
if bars_df is None or bars_df.empty:
|
|
2686
|
+
raise HTTPException(status_code=404, detail="No bar data found for this symbol")
|
|
2687
|
+
|
|
2688
|
+
bars_before, bars_after = 100, 100
|
|
2689
|
+
mask_start = bars_df["ts_event"] >= submission_time
|
|
2690
|
+
if not mask_start.any():
|
|
2691
|
+
raise HTTPException(
|
|
2692
|
+
status_code=404, detail="Submission time not found in bar data"
|
|
2693
|
+
)
|
|
2694
|
+
|
|
2695
|
+
loc_result = bars_df.index.get_loc(bars_df[mask_start].index[0])
|
|
2696
|
+
start_idx = (
|
|
2697
|
+
int(loc_result)
|
|
2698
|
+
if isinstance(loc_result, int)
|
|
2699
|
+
else int(loc_result.start)
|
|
2700
|
+
if isinstance(loc_result, slice)
|
|
2701
|
+
else int(loc_result.argmax())
|
|
2702
|
+
)
|
|
2703
|
+
if resolution_time:
|
|
2704
|
+
mask_end = bars_df["ts_event"] <= resolution_time
|
|
2705
|
+
if mask_end.any():
|
|
2706
|
+
loc_result_end = bars_df.index.get_loc(bars_df[mask_end].index[-1])
|
|
2707
|
+
end_idx = (
|
|
2708
|
+
int(loc_result_end)
|
|
2709
|
+
if isinstance(loc_result_end, int)
|
|
2710
|
+
else int(loc_result_end.start)
|
|
2711
|
+
if isinstance(loc_result_end, slice)
|
|
2712
|
+
else int(loc_result_end.argmax())
|
|
2713
|
+
)
|
|
2714
|
+
else:
|
|
2715
|
+
end_idx = start_idx
|
|
2716
|
+
else:
|
|
2717
|
+
end_idx = min(start_idx + 50, len(bars_df) - 1)
|
|
2718
|
+
|
|
2719
|
+
chart_start = max(0, start_idx - bars_before)
|
|
2720
|
+
chart_end = min(len(bars_df) - 1, end_idx + bars_after)
|
|
2721
|
+
data = bars_df.iloc[chart_start : chart_end + 1].copy()
|
|
2722
|
+
highlight_start = start_idx - chart_start
|
|
2723
|
+
highlight_end = end_idx - chart_start
|
|
2724
|
+
|
|
2725
|
+
chart_dir = CHARTS_DIR / run_id / "signals"
|
|
2726
|
+
chart_dir.mkdir(parents=True, exist_ok=True)
|
|
2727
|
+
chart_path = chart_dir / f"{order_id}.png"
|
|
2728
|
+
|
|
2729
|
+
order_info = {
|
|
2730
|
+
"submission_time": submission_time,
|
|
2731
|
+
"resolution_time": resolution_time,
|
|
2732
|
+
"resolution_type": resolution_type,
|
|
2733
|
+
"side": side or "BUY",
|
|
2734
|
+
"order_type": order_type,
|
|
2735
|
+
"quantity": quantity,
|
|
2736
|
+
"limit_price": limit_price,
|
|
2737
|
+
"stop_price": stop_price,
|
|
2738
|
+
"fill_price": fill_price,
|
|
2739
|
+
"action": action,
|
|
2740
|
+
"signal": signal,
|
|
2741
|
+
}
|
|
2742
|
+
_generate_signal_chart(
|
|
2743
|
+
chart_path, data, order_info, symbol, highlight_start, highlight_end
|
|
2744
|
+
)
|
|
2745
|
+
return FileResponse(chart_path, media_type="image/png")
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
def _generate_signal_chart(
|
|
2749
|
+
output_path: pathlib.Path,
|
|
2750
|
+
data: pd.DataFrame,
|
|
2751
|
+
order_info: dict,
|
|
2752
|
+
symbol: str,
|
|
2753
|
+
highlight_start: int,
|
|
2754
|
+
highlight_end: int,
|
|
2755
|
+
) -> None:
|
|
2756
|
+
groups = _subplot_groups_from_data(data)
|
|
2757
|
+
n = 1 + len(groups)
|
|
2758
|
+
ratios = [4] + [1] * len(groups)
|
|
2759
|
+
fig, axes = plt.subplots(
|
|
2760
|
+
n, 1, figsize=(16, 10), sharex=True, gridspec_kw={"height_ratios": ratios}
|
|
2761
|
+
)
|
|
2762
|
+
axes = [axes] if n == 1 else list(axes)
|
|
2763
|
+
ax_main = axes[0]
|
|
2764
|
+
|
|
2765
|
+
_plot_price_data(ax_main, data, highlight_start, highlight_end)
|
|
2766
|
+
_plot_main_indicators(ax_main, data)
|
|
2767
|
+
_plot_signal_markers(ax_main, data, order_info)
|
|
2768
|
+
|
|
2769
|
+
for i, (_, ind_cols) in enumerate(sorted(groups.items())):
|
|
2770
|
+
ax_sub = axes[i + 1]
|
|
2771
|
+
for j, (col, display_name) in enumerate(ind_cols):
|
|
2772
|
+
if col in data.columns:
|
|
2773
|
+
ax_sub.plot(
|
|
2774
|
+
data["ts_event"],
|
|
2775
|
+
data[col],
|
|
2776
|
+
label=display_name,
|
|
2777
|
+
linewidth=1.5,
|
|
2778
|
+
alpha=0.8,
|
|
2779
|
+
color=_SUBPLOT_COLORS[j % len(_SUBPLOT_COLORS)],
|
|
2780
|
+
)
|
|
2781
|
+
ax_sub.legend(loc="upper left", fontsize=8)
|
|
2782
|
+
ax_sub.grid(True, alpha=0.3)
|
|
2783
|
+
|
|
2784
|
+
status = order_info["resolution_type"] or "PENDING"
|
|
2785
|
+
title = f"Signal: {order_info['signal'] or 'unnamed'} - {symbol} - {order_info['action'] or 'UNKNOWN'} - {status}"
|
|
2786
|
+
ax_main.set_title(title, fontsize=14, fontweight="bold")
|
|
2787
|
+
|
|
2788
|
+
total_seconds = (
|
|
2789
|
+
data["ts_event"].iloc[-1] - data["ts_event"].iloc[0]
|
|
2790
|
+
).total_seconds()
|
|
2791
|
+
hours = total_seconds / 3600
|
|
2792
|
+
days = total_seconds / 86400
|
|
2793
|
+
|
|
2794
|
+
for a in axes:
|
|
2795
|
+
if days > 30:
|
|
2796
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
|
|
2797
|
+
a.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
|
|
2798
|
+
elif days > 7:
|
|
2799
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
|
|
2800
|
+
a.xaxis.set_major_locator(mdates.DayLocator(interval=2))
|
|
2801
|
+
elif days > 2:
|
|
2802
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
|
|
2803
|
+
a.xaxis.set_major_locator(mdates.DayLocator(interval=1))
|
|
2804
|
+
elif hours > 24:
|
|
2805
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d %H:%M"))
|
|
2806
|
+
a.xaxis.set_major_locator(mdates.HourLocator(interval=6))
|
|
2807
|
+
elif hours > 8:
|
|
2808
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
2809
|
+
a.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
|
2810
|
+
elif hours > 2:
|
|
2811
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
2812
|
+
a.xaxis.set_major_locator(mdates.HourLocator(interval=1))
|
|
2813
|
+
elif hours > 0.5:
|
|
2814
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
2815
|
+
a.xaxis.set_major_locator(mdates.MinuteLocator(interval=15))
|
|
2816
|
+
else:
|
|
2817
|
+
a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
|
|
2818
|
+
a.xaxis.set_major_locator(mdates.MinuteLocator(interval=5))
|
|
2819
|
+
|
|
2820
|
+
plt.xticks(rotation=45, fontsize=9)
|
|
2821
|
+
plt.tight_layout()
|
|
2822
|
+
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
|
2823
|
+
plt.close(fig)
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def _plot_signal_markers(ax, data: pd.DataFrame, order_info: dict) -> None:
|
|
2827
|
+
submission_time = order_info["submission_time"]
|
|
2828
|
+
mask = data["ts_event"] >= submission_time
|
|
2829
|
+
if mask.any():
|
|
2830
|
+
idx = data[mask].index[0]
|
|
2831
|
+
price = data.loc[idx, "close"]
|
|
2832
|
+
marker = "^" if order_info["side"] == "BUY" else "v"
|
|
2833
|
+
color = "blue"
|
|
2834
|
+
ax.scatter(
|
|
2835
|
+
submission_time,
|
|
2836
|
+
price,
|
|
2837
|
+
marker=marker,
|
|
2838
|
+
color=color,
|
|
2839
|
+
s=200,
|
|
2840
|
+
edgecolors="black",
|
|
2841
|
+
linewidth=1.5,
|
|
2842
|
+
zorder=5,
|
|
2843
|
+
alpha=0.9,
|
|
2844
|
+
label="Submission",
|
|
2845
|
+
)
|
|
2846
|
+
y_lim = ax.get_ylim()
|
|
2847
|
+
offset_y = (y_lim[1] - y_lim[0]) * 0.03
|
|
2848
|
+
label_text = f"{order_info['side']} {order_info['quantity'] or ''}"
|
|
2849
|
+
ax.annotate(
|
|
2850
|
+
label_text,
|
|
2851
|
+
(submission_time, price + offset_y),
|
|
2852
|
+
ha="center",
|
|
2853
|
+
va="bottom",
|
|
2854
|
+
fontsize=9,
|
|
2855
|
+
fontweight="bold",
|
|
2856
|
+
bbox=dict(boxstyle="round,pad=0.3", facecolor="lightyellow", alpha=0.9),
|
|
2857
|
+
)
|
|
2858
|
+
|
|
2859
|
+
if order_info["resolution_time"]:
|
|
2860
|
+
res_time = order_info["resolution_time"]
|
|
2861
|
+
mask_res = data["ts_event"] >= res_time
|
|
2862
|
+
if mask_res.any():
|
|
2863
|
+
idx_res = data[mask_res].index[0]
|
|
2864
|
+
if order_info["fill_price"]:
|
|
2865
|
+
res_price = order_info["fill_price"]
|
|
2866
|
+
else:
|
|
2867
|
+
res_price = data.loc[idx_res, "close"]
|
|
2868
|
+
if order_info["resolution_type"] == "FILLED":
|
|
2869
|
+
res_marker = "o"
|
|
2870
|
+
res_color = "green"
|
|
2871
|
+
else:
|
|
2872
|
+
res_marker = "x"
|
|
2873
|
+
res_color = "red"
|
|
2874
|
+
ax.scatter(
|
|
2875
|
+
res_time,
|
|
2876
|
+
res_price,
|
|
2877
|
+
marker=res_marker,
|
|
2878
|
+
color=res_color,
|
|
2879
|
+
s=200,
|
|
2880
|
+
edgecolors="black",
|
|
2881
|
+
linewidth=1.5,
|
|
2882
|
+
zorder=5,
|
|
2883
|
+
alpha=0.9,
|
|
2884
|
+
label=order_info["resolution_type"],
|
|
2885
|
+
)
|
|
2886
|
+
ax.legend(loc="upper right", fontsize=8)
|
|
2887
|
+
|
|
2888
|
+
|
|
2889
|
+
@app.get("/pipeline/signal-validation", response_class=HTMLResponse)
|
|
2890
|
+
async def signal_validation_page():
|
|
2891
|
+
sidebar = SIDEBAR_HTML.format(
|
|
2892
|
+
runs_active="",
|
|
2893
|
+
backtest_active="",
|
|
2894
|
+
secmaster_active="",
|
|
2895
|
+
pipeline_active="active",
|
|
2896
|
+
pipeline_open="open",
|
|
2897
|
+
pipeline_sub1_active="active",
|
|
2898
|
+
pipeline_sub2_active="",
|
|
2899
|
+
)
|
|
2900
|
+
return f"""
|
|
2901
|
+
<!DOCTYPE html>
|
|
2902
|
+
<html>
|
|
2903
|
+
<head>
|
|
2904
|
+
<title>Signal Validation - OneSecondTrader</title>
|
|
2905
|
+
<style>{BASE_STYLE}{SIGNAL_VALIDATION_STYLE}{RUN_DETAIL_STYLE}</style>
|
|
2906
|
+
</head>
|
|
2907
|
+
<body>
|
|
2908
|
+
{sidebar}
|
|
2909
|
+
<main class="main-content" style="padding: 0;">
|
|
2910
|
+
<div class="sv-layout">
|
|
2911
|
+
<div class="sv-sidebar">
|
|
2912
|
+
<div class="sv-run-selector">
|
|
2913
|
+
<label>Run</label>
|
|
2914
|
+
<select id="run-select" onchange="onRunChange()">
|
|
2915
|
+
<option value="">Loading...</option>
|
|
2916
|
+
</select>
|
|
2917
|
+
</div>
|
|
2918
|
+
<div id="action-groups"></div>
|
|
2919
|
+
</div>
|
|
2920
|
+
<div class="sv-main">
|
|
2921
|
+
<div id="sv-content">
|
|
2922
|
+
<div class="sv-empty">Select a run from the dropdown to begin</div>
|
|
2923
|
+
</div>
|
|
2924
|
+
</div>
|
|
2925
|
+
</div>
|
|
2926
|
+
</main>
|
|
2927
|
+
<div id="chart-modal" class="chart-modal" onclick="if(event.target===this)closeChartModal()">
|
|
2928
|
+
<div class="chart-modal-content">
|
|
2929
|
+
<div class="chart-modal-header">
|
|
2930
|
+
<h3 id="chart-modal-title">Signal Chart</h3>
|
|
2931
|
+
<button class="chart-modal-close" onclick="closeChartModal()">×</button>
|
|
2932
|
+
</div>
|
|
2933
|
+
<div id="chart-modal-body" class="chart-modal-body"></div>
|
|
2934
|
+
</div>
|
|
2935
|
+
</div>
|
|
2936
|
+
<script>{SIGNAL_VALIDATION_SCRIPT}</script>
|
|
2937
|
+
</body>
|
|
2938
|
+
</html>
|
|
2939
|
+
"""
|
|
2940
|
+
|
|
2941
|
+
|
|
2942
|
+
@app.get("/pipeline/sub2", response_class=HTMLResponse)
|
|
2943
|
+
async def pipeline_sub2():
|
|
2944
|
+
sidebar = SIDEBAR_HTML.format(
|
|
2945
|
+
runs_active="",
|
|
2946
|
+
backtest_active="",
|
|
2947
|
+
secmaster_active="",
|
|
2948
|
+
pipeline_active="active",
|
|
2949
|
+
pipeline_open="open",
|
|
2950
|
+
pipeline_sub1_active="",
|
|
2951
|
+
pipeline_sub2_active="active",
|
|
2952
|
+
)
|
|
2953
|
+
return f"""
|
|
2954
|
+
<!DOCTYPE html>
|
|
2955
|
+
<html>
|
|
2956
|
+
<head>
|
|
2957
|
+
<title>Sub Tab 2 - OneSecondTrader</title>
|
|
2958
|
+
<style>{BASE_STYLE}</style>
|
|
2959
|
+
</head>
|
|
2960
|
+
<body>
|
|
2961
|
+
{sidebar}
|
|
2962
|
+
<main class="main-content">
|
|
2963
|
+
<div class="container">
|
|
2964
|
+
<div class="card">
|
|
2965
|
+
<h2>Sub Tab 2</h2>
|
|
2966
|
+
<p style="color: #8b949e;">This is Sub Tab 2 of the Dev Pipeline.</p>
|
|
2967
|
+
</div>
|
|
2968
|
+
</div>
|
|
2969
|
+
</main>
|
|
2970
|
+
</body>
|
|
2971
|
+
</html>
|
|
2972
|
+
"""
|