onesecondtrader 0.40.0__py3-none-any.whl → 0.41.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 +2 -0
- onesecondtrader/connectors/datafeeds/simulated.py +21 -6
- onesecondtrader/core/datafeeds/base.py +3 -0
- onesecondtrader/core/models/__init__.py +2 -0
- onesecondtrader/core/models/params.py +21 -0
- onesecondtrader/core/strategies/base.py +9 -3
- onesecondtrader/core/strategies/examples.py +15 -7
- onesecondtrader/dashboard/__init__.py +3 -0
- onesecondtrader/dashboard/app.py +1677 -0
- onesecondtrader/dashboard/registry.py +100 -0
- onesecondtrader/orchestrator/__init__.py +7 -0
- onesecondtrader/orchestrator/orchestrator.py +105 -0
- onesecondtrader/orchestrator/recorder.py +196 -0
- onesecondtrader/orchestrator/schema.sql +208 -0
- onesecondtrader/secmaster/schema.sql +48 -0
- onesecondtrader/secmaster/utils.py +90 -0
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.41.0.dist-info}/METADATA +3 -1
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.41.0.dist-info}/RECORD +20 -13
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.41.0.dist-info}/WHEEL +0 -0
- {onesecondtrader-0.40.0.dist-info → onesecondtrader-0.41.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1677 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, BackgroundTasks
|
|
8
|
+
from fastapi.responses import HTMLResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from onesecondtrader.orchestrator import Orchestrator
|
|
12
|
+
from onesecondtrader.connectors.brokers import SimulatedBroker
|
|
13
|
+
from onesecondtrader.connectors.datafeeds import SimulatedDatafeed
|
|
14
|
+
from . import registry
|
|
15
|
+
|
|
16
|
+
app = FastAPI(title="OneSecondTrader Dashboard")
|
|
17
|
+
|
|
18
|
+
_running_jobs: dict[str, str] = {}
|
|
19
|
+
|
|
20
|
+
RTYPE_TO_BAR_PERIOD = {32: "SECOND", 33: "MINUTE", 34: "HOUR", 35: "DAY"}
|
|
21
|
+
BAR_PERIOD_TO_RTYPE = {"SECOND": 32, "MINUTE": 33, "HOUR": 34, "DAY": 35}
|
|
22
|
+
BAR_PERIOD_ENUM_TO_NAME = {1: "SECOND", 2: "MINUTE", 3: "HOUR", 4: "DAY"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_bar_period(value) -> str | None:
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
if value in BAR_PERIOD_TO_RTYPE:
|
|
30
|
+
return value
|
|
31
|
+
try:
|
|
32
|
+
value = int(value)
|
|
33
|
+
except ValueError:
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, int):
|
|
36
|
+
return BAR_PERIOD_ENUM_TO_NAME.get(value, str(value))
|
|
37
|
+
return str(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_secmaster_path() -> str:
|
|
41
|
+
return os.environ.get("SECMASTER_DB_PATH", "secmaster.db")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_available_symbols(bar_period: str | None = None) -> list[str]:
|
|
45
|
+
db_path = _get_secmaster_path()
|
|
46
|
+
if not os.path.exists(db_path):
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
conn = sqlite3.connect(db_path)
|
|
50
|
+
cursor = conn.cursor()
|
|
51
|
+
if bar_period and bar_period in BAR_PERIOD_TO_RTYPE:
|
|
52
|
+
rtype = BAR_PERIOD_TO_RTYPE[bar_period]
|
|
53
|
+
cursor.execute(
|
|
54
|
+
"SELECT DISTINCT symbol FROM symbol_coverage WHERE rtype = ? ORDER BY symbol",
|
|
55
|
+
(rtype,),
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
cursor.execute("SELECT DISTINCT symbol FROM symbol_coverage ORDER BY symbol")
|
|
59
|
+
symbols = [row[0] for row in cursor.fetchall()]
|
|
60
|
+
conn.close()
|
|
61
|
+
return symbols
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_symbols_with_coverage(bar_period: str) -> list[dict]:
|
|
65
|
+
db_path = _get_secmaster_path()
|
|
66
|
+
if not os.path.exists(db_path) or bar_period not in BAR_PERIOD_TO_RTYPE:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
rtype = BAR_PERIOD_TO_RTYPE[bar_period]
|
|
70
|
+
conn = sqlite3.connect(db_path)
|
|
71
|
+
cursor = conn.cursor()
|
|
72
|
+
cursor.execute(
|
|
73
|
+
"SELECT symbol, min_ts, max_ts FROM symbol_coverage WHERE rtype = ? ORDER BY symbol",
|
|
74
|
+
(rtype,),
|
|
75
|
+
)
|
|
76
|
+
symbols = [
|
|
77
|
+
{"symbol": row[0], "min_ts": row[1], "max_ts": row[2]}
|
|
78
|
+
for row in cursor.fetchall()
|
|
79
|
+
]
|
|
80
|
+
conn.close()
|
|
81
|
+
return symbols
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_available_bar_periods() -> list[str]:
|
|
85
|
+
db_path = _get_secmaster_path()
|
|
86
|
+
if not os.path.exists(db_path):
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
conn = sqlite3.connect(db_path)
|
|
90
|
+
cursor = conn.cursor()
|
|
91
|
+
cursor.execute("SELECT DISTINCT rtype FROM symbol_coverage ORDER BY rtype")
|
|
92
|
+
periods = [
|
|
93
|
+
RTYPE_TO_BAR_PERIOD[row[0]]
|
|
94
|
+
for row in cursor.fetchall()
|
|
95
|
+
if row[0] in RTYPE_TO_BAR_PERIOD
|
|
96
|
+
]
|
|
97
|
+
conn.close()
|
|
98
|
+
return periods
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
BASE_STYLE = """
|
|
102
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
103
|
+
body {
|
|
104
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
105
|
+
background: #0d1117;
|
|
106
|
+
color: #e6edf3;
|
|
107
|
+
min-height: 100vh;
|
|
108
|
+
display: flex;
|
|
109
|
+
}
|
|
110
|
+
.sidebar {
|
|
111
|
+
width: 220px;
|
|
112
|
+
background: #161b22;
|
|
113
|
+
border-right: 1px solid #30363d;
|
|
114
|
+
min-height: 100vh;
|
|
115
|
+
position: fixed;
|
|
116
|
+
left: 0;
|
|
117
|
+
top: 0;
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
}
|
|
121
|
+
.sidebar-header {
|
|
122
|
+
padding: 20px 16px;
|
|
123
|
+
border-bottom: 1px solid #30363d;
|
|
124
|
+
}
|
|
125
|
+
.sidebar-header h1 {
|
|
126
|
+
font-size: 16px;
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
color: #e6edf3;
|
|
129
|
+
}
|
|
130
|
+
.sidebar-nav {
|
|
131
|
+
padding: 12px 8px;
|
|
132
|
+
flex: 1;
|
|
133
|
+
}
|
|
134
|
+
.sidebar-nav a {
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 10px;
|
|
138
|
+
padding: 10px 12px;
|
|
139
|
+
color: #8b949e;
|
|
140
|
+
text-decoration: none;
|
|
141
|
+
font-size: 14px;
|
|
142
|
+
border-radius: 6px;
|
|
143
|
+
margin-bottom: 2px;
|
|
144
|
+
}
|
|
145
|
+
.sidebar-nav a:hover {
|
|
146
|
+
background: #21262d;
|
|
147
|
+
color: #e6edf3;
|
|
148
|
+
}
|
|
149
|
+
.sidebar-nav a.active {
|
|
150
|
+
background: #21262d;
|
|
151
|
+
color: #e6edf3;
|
|
152
|
+
}
|
|
153
|
+
.sidebar-nav svg {
|
|
154
|
+
width: 16px;
|
|
155
|
+
height: 16px;
|
|
156
|
+
flex-shrink: 0;
|
|
157
|
+
}
|
|
158
|
+
.main-content {
|
|
159
|
+
margin-left: 220px;
|
|
160
|
+
flex: 1;
|
|
161
|
+
min-height: 100vh;
|
|
162
|
+
}
|
|
163
|
+
.container {
|
|
164
|
+
max-width: 1200px;
|
|
165
|
+
margin: 0 auto;
|
|
166
|
+
padding: 32px 24px;
|
|
167
|
+
}
|
|
168
|
+
.card {
|
|
169
|
+
background: #161b22;
|
|
170
|
+
border: 1px solid #30363d;
|
|
171
|
+
border-radius: 8px;
|
|
172
|
+
padding: 24px;
|
|
173
|
+
margin-bottom: 16px;
|
|
174
|
+
}
|
|
175
|
+
.card h2 {
|
|
176
|
+
font-size: 16px;
|
|
177
|
+
font-weight: 600;
|
|
178
|
+
margin-bottom: 16px;
|
|
179
|
+
color: #e6edf3;
|
|
180
|
+
}
|
|
181
|
+
.empty-state {
|
|
182
|
+
text-align: center;
|
|
183
|
+
padding: 48px;
|
|
184
|
+
color: #8b949e;
|
|
185
|
+
}
|
|
186
|
+
.empty-state p { margin-top: 8px; font-size: 14px; }
|
|
187
|
+
.badge {
|
|
188
|
+
display: inline-block;
|
|
189
|
+
padding: 4px 10px;
|
|
190
|
+
border-radius: 12px;
|
|
191
|
+
font-size: 12px;
|
|
192
|
+
font-weight: 500;
|
|
193
|
+
}
|
|
194
|
+
.badge-green { background: #238636; color: #fff; }
|
|
195
|
+
.badge-yellow { background: #9e6a03; color: #fff; }
|
|
196
|
+
.badge-red { background: #da3633; color: #fff; }
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
SIDEBAR_HTML = """
|
|
201
|
+
<aside class="sidebar">
|
|
202
|
+
<div class="sidebar-header">
|
|
203
|
+
<h1>OneSecondTrader</h1>
|
|
204
|
+
</div>
|
|
205
|
+
<nav class="sidebar-nav">
|
|
206
|
+
<a href="/" class="{runs_active}">
|
|
207
|
+
<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>
|
|
208
|
+
Runs
|
|
209
|
+
</a>
|
|
210
|
+
<a href="/backtest" class="{backtest_active}">
|
|
211
|
+
<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>
|
|
212
|
+
Backtest
|
|
213
|
+
</a>
|
|
214
|
+
<a href="/secmaster" class="{secmaster_active}">
|
|
215
|
+
<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>
|
|
216
|
+
Securities Master
|
|
217
|
+
</a>
|
|
218
|
+
</nav>
|
|
219
|
+
</aside>
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
RUNS_STYLE = """
|
|
224
|
+
.runs-table { width: 100%; border-collapse: collapse; }
|
|
225
|
+
.runs-table th, .runs-table td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
|
226
|
+
.runs-table th { font-size: 12px; color: #8b949e; text-transform: uppercase; font-weight: 500; }
|
|
227
|
+
.runs-table tbody tr { cursor: pointer; }
|
|
228
|
+
.runs-table tbody tr:hover { background: #21262d; }
|
|
229
|
+
.runs-table .symbols { font-family: monospace; font-size: 12px; color: #8b949e; }
|
|
230
|
+
.runs-table .date-range { font-size: 12px; color: #8b949e; }
|
|
231
|
+
.runs-table .delete-btn { background: none; border: none; cursor: pointer; padding: 4px 8px; color: #8b949e; font-size: 16px; border-radius: 4px; }
|
|
232
|
+
.runs-table .delete-btn:hover { background: #f8514926; color: #f85149; }
|
|
233
|
+
.runs-table .actions { text-align: center; width: 50px; }
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
RUNS_SCRIPT = """
|
|
237
|
+
async function deleteRun(runId, event) {
|
|
238
|
+
event.stopPropagation();
|
|
239
|
+
if (!confirm('Delete this run? This cannot be undone.')) return;
|
|
240
|
+
const res = await fetch(`/api/run/${runId}`, { method: 'DELETE' });
|
|
241
|
+
if (res.ok) loadRuns();
|
|
242
|
+
else alert('Failed to delete run');
|
|
243
|
+
}
|
|
244
|
+
async function loadRuns() {
|
|
245
|
+
const container = document.getElementById('runs-content');
|
|
246
|
+
const res = await fetch('/api/runs');
|
|
247
|
+
const data = await res.json();
|
|
248
|
+
if (!data.runs || data.runs.length === 0) {
|
|
249
|
+
container.innerHTML = `
|
|
250
|
+
<div class="empty-state">
|
|
251
|
+
<p>No runs yet.</p>
|
|
252
|
+
<p>Run a strategy with RunRecorder to see results here.</p>
|
|
253
|
+
</div>
|
|
254
|
+
`;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const rows = data.runs.map(r => {
|
|
258
|
+
const statusClass = r.status === 'completed' ? 'badge-green' : r.status === 'running' ? 'badge-yellow' : 'badge-red';
|
|
259
|
+
const createdAt = r.created_at ? r.created_at.split('T')[0] + ' ' + r.created_at.split('T')[1].split('.')[0] : '-';
|
|
260
|
+
const dateRange = r.first_bar && r.last_bar
|
|
261
|
+
? r.first_bar.split('T')[0] + ' → ' + r.last_bar.split('T')[0]
|
|
262
|
+
: '-';
|
|
263
|
+
return `<tr onclick="window.location='/run/${r.run_id}'">
|
|
264
|
+
<td>${createdAt}</td>
|
|
265
|
+
<td>${r.strategy}</td>
|
|
266
|
+
<td class="symbols">${r.symbols.length}</td>
|
|
267
|
+
<td>${r.bar_period || '-'}</td>
|
|
268
|
+
<td class="date-range">${dateRange}</td>
|
|
269
|
+
<td><span class="badge ${statusClass}">${r.status}</span></td>
|
|
270
|
+
<td class="actions"><button class="delete-btn" onclick="deleteRun('${r.run_id}', event)" title="Delete run">🗑</button></td>
|
|
271
|
+
</tr>`;
|
|
272
|
+
}).join('');
|
|
273
|
+
container.innerHTML = `
|
|
274
|
+
<table class="runs-table">
|
|
275
|
+
<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>
|
|
276
|
+
<tbody>${rows}</tbody>
|
|
277
|
+
</table>
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
document.addEventListener('DOMContentLoaded', loadRuns);
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.get("/", response_class=HTMLResponse)
|
|
285
|
+
async def index():
|
|
286
|
+
sidebar = SIDEBAR_HTML.format(
|
|
287
|
+
runs_active="active", backtest_active="", secmaster_active=""
|
|
288
|
+
)
|
|
289
|
+
return f"""
|
|
290
|
+
<!DOCTYPE html>
|
|
291
|
+
<html>
|
|
292
|
+
<head>
|
|
293
|
+
<title>OneSecondTrader Dashboard</title>
|
|
294
|
+
<style>{BASE_STYLE}{RUNS_STYLE}</style>
|
|
295
|
+
</head>
|
|
296
|
+
<body>
|
|
297
|
+
{sidebar}
|
|
298
|
+
<main class="main-content">
|
|
299
|
+
<div class="container">
|
|
300
|
+
<div class="card">
|
|
301
|
+
<h2>Recent Runs</h2>
|
|
302
|
+
<div id="runs-content">
|
|
303
|
+
<p style="color: #8b949e;">Loading...</p>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</main>
|
|
308
|
+
<script>{RUNS_SCRIPT}</script>
|
|
309
|
+
</body>
|
|
310
|
+
</html>
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
RUN_DETAIL_STYLE = """
|
|
315
|
+
.run-header { margin-bottom: 24px; }
|
|
316
|
+
.run-header h2 { margin-bottom: 8px; }
|
|
317
|
+
.run-meta { display: flex; gap: 24px; flex-wrap: wrap; color: #8b949e; font-size: 14px; }
|
|
318
|
+
.run-meta span { display: flex; align-items: center; gap: 6px; }
|
|
319
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
320
|
+
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px; }
|
|
321
|
+
.stat-card .label { font-size: 12px; color: #8b949e; margin-bottom: 4px; }
|
|
322
|
+
.stat-card .value { font-size: 24px; font-weight: 600; }
|
|
323
|
+
.back-link { color: #58a6ff; text-decoration: none; font-size: 14px; display: inline-block; margin-bottom: 16px; }
|
|
324
|
+
.back-link:hover { text-decoration: underline; }
|
|
325
|
+
.positions-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
|
|
326
|
+
.positions-table th { text-align: left; padding: 12px 8px; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 500; }
|
|
327
|
+
.positions-table td { padding: 10px 8px; border-bottom: 1px solid #21262d; }
|
|
328
|
+
.positions-table tr:hover { background: #161b22; }
|
|
329
|
+
.pnl-positive { color: #3fb950; }
|
|
330
|
+
.pnl-negative { color: #f85149; }
|
|
331
|
+
.side-long { color: #3fb950; }
|
|
332
|
+
.side-short { color: #f85149; }
|
|
333
|
+
.positions-summary { display: flex; gap: 24px; margin-bottom: 16px; padding: 12px; background: #161b22; border-radius: 6px; }
|
|
334
|
+
.positions-summary .item { display: flex; flex-direction: column; }
|
|
335
|
+
.positions-summary .item-label { font-size: 11px; color: #8b949e; text-transform: uppercase; }
|
|
336
|
+
.positions-summary .item-value { font-size: 18px; font-weight: 600; }
|
|
337
|
+
.positions-filter { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
|
338
|
+
.positions-filter select { background: #21262d; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; color: #c9d1d9; font-size: 14px; min-width: 150px; }
|
|
339
|
+
.positions-filter select:focus { outline: none; border-color: #58a6ff; }
|
|
340
|
+
.positions-filter label { color: #8b949e; font-size: 14px; }
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
RUN_DETAIL_SCRIPT = """
|
|
344
|
+
let allPositions = [];
|
|
345
|
+
function formatTime(ts) {
|
|
346
|
+
if (!ts) return '-';
|
|
347
|
+
return ts.split('T')[0] + ' ' + ts.split('T')[1].split('.')[0];
|
|
348
|
+
}
|
|
349
|
+
function formatPnl(pnl) {
|
|
350
|
+
const cls = pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
351
|
+
const sign = pnl >= 0 ? '+' : '';
|
|
352
|
+
return `<span class="${cls}">${sign}$${pnl.toFixed(2)}</span>`;
|
|
353
|
+
}
|
|
354
|
+
function computeSummary(positions) {
|
|
355
|
+
const totalPnl = positions.reduce((sum, p) => sum + p.pnl, 0);
|
|
356
|
+
const winners = positions.filter(p => p.pnl > 0).length;
|
|
357
|
+
const losers = positions.filter(p => p.pnl < 0).length;
|
|
358
|
+
const winRate = positions.length > 0 ? winners / positions.length : 0;
|
|
359
|
+
return { total_positions: positions.length, total_pnl: totalPnl, winners, losers, win_rate: winRate };
|
|
360
|
+
}
|
|
361
|
+
function renderPositions(positions, selectedSymbol) {
|
|
362
|
+
const filtered = selectedSymbol ? positions.filter(p => p.symbol === selectedSymbol) : positions;
|
|
363
|
+
const s = computeSummary(filtered);
|
|
364
|
+
const winRate = s.win_rate ? (s.win_rate * 100).toFixed(1) + '%' : '-';
|
|
365
|
+
const summaryPnlClass = s.total_pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
366
|
+
document.getElementById('summary-pnl').className = 'item-value ' + summaryPnlClass;
|
|
367
|
+
document.getElementById('summary-pnl').textContent = (s.total_pnl >= 0 ? '+' : '') + '$' + s.total_pnl.toFixed(2);
|
|
368
|
+
document.getElementById('summary-positions').textContent = s.total_positions;
|
|
369
|
+
document.getElementById('summary-winners').textContent = s.winners;
|
|
370
|
+
document.getElementById('summary-losers').textContent = s.losers;
|
|
371
|
+
document.getElementById('summary-winrate').textContent = winRate;
|
|
372
|
+
const tbody = document.getElementById('positions-tbody');
|
|
373
|
+
tbody.innerHTML = filtered.map((p, i) => `
|
|
374
|
+
<tr>
|
|
375
|
+
<td>${i + 1}</td>
|
|
376
|
+
<td>${p.symbol}</td>
|
|
377
|
+
<td class="${p.side === 'LONG' ? 'side-long' : 'side-short'}">${p.side}</td>
|
|
378
|
+
<td>${p.quantity}</td>
|
|
379
|
+
<td>${formatTime(p.entry_time)}</td>
|
|
380
|
+
<td>${formatTime(p.exit_time)}</td>
|
|
381
|
+
<td>$${p.avg_entry_price.toFixed(2)}</td>
|
|
382
|
+
<td>$${p.avg_exit_price.toFixed(2)}</td>
|
|
383
|
+
<td>${formatPnl(p.pnl)}</td>
|
|
384
|
+
</tr>
|
|
385
|
+
`).join('');
|
|
386
|
+
}
|
|
387
|
+
function onSymbolFilterChange() {
|
|
388
|
+
const sel = document.getElementById('symbol-filter');
|
|
389
|
+
renderPositions(allPositions, sel.value);
|
|
390
|
+
}
|
|
391
|
+
async function loadRunDetail() {
|
|
392
|
+
const runId = window.location.pathname.split('/run/')[1];
|
|
393
|
+
const container = document.getElementById('run-content');
|
|
394
|
+
const [runRes, posRes] = await Promise.all([
|
|
395
|
+
fetch(`/api/run/${runId}`),
|
|
396
|
+
fetch(`/api/run/${runId}/positions`)
|
|
397
|
+
]);
|
|
398
|
+
if (!runRes.ok) {
|
|
399
|
+
container.innerHTML = '<p style="color: #f85149;">Run not found.</p>';
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const r = await runRes.json();
|
|
403
|
+
const posData = await posRes.json();
|
|
404
|
+
allPositions = posData.positions || [];
|
|
405
|
+
const statusClass = r.status === 'completed' ? 'badge-green' : r.status === 'running' ? 'badge-yellow' : 'badge-red';
|
|
406
|
+
const createdAt = formatTime(r.created_at);
|
|
407
|
+
const dateRange = r.first_bar && r.last_bar
|
|
408
|
+
? r.first_bar.split('T')[0] + ' → ' + r.last_bar.split('T')[0]
|
|
409
|
+
: '-';
|
|
410
|
+
const symbols = [...new Set(allPositions.map(p => p.symbol))].sort();
|
|
411
|
+
const s = computeSummary(allPositions);
|
|
412
|
+
const winRate = s.win_rate ? (s.win_rate * 100).toFixed(1) + '%' : '-';
|
|
413
|
+
let positionsHtml = '';
|
|
414
|
+
if (allPositions.length > 0) {
|
|
415
|
+
const summaryPnlClass = s.total_pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
|
|
416
|
+
const symbolOptions = symbols.map(sym => `<option value="${sym}">${sym}</option>`).join('');
|
|
417
|
+
positionsHtml = `
|
|
418
|
+
<div class="card" style="margin-top: 24px;">
|
|
419
|
+
<h3>Positions (Round-Trip Trades)</h3>
|
|
420
|
+
<div class="positions-filter">
|
|
421
|
+
<label for="symbol-filter">Filter by Symbol:</label>
|
|
422
|
+
<select id="symbol-filter" onchange="onSymbolFilterChange()">
|
|
423
|
+
<option value="">All Symbols</option>
|
|
424
|
+
${symbolOptions}
|
|
425
|
+
</select>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="positions-summary">
|
|
428
|
+
<div class="item">
|
|
429
|
+
<span class="item-label">Total P&L</span>
|
|
430
|
+
<span id="summary-pnl" class="item-value ${summaryPnlClass}">${s.total_pnl >= 0 ? '+' : ''}$${s.total_pnl.toFixed(2)}</span>
|
|
431
|
+
</div>
|
|
432
|
+
<div class="item">
|
|
433
|
+
<span class="item-label">Positions</span>
|
|
434
|
+
<span id="summary-positions" class="item-value">${s.total_positions}</span>
|
|
435
|
+
</div>
|
|
436
|
+
<div class="item">
|
|
437
|
+
<span class="item-label">Winners</span>
|
|
438
|
+
<span id="summary-winners" class="item-value pnl-positive">${s.winners}</span>
|
|
439
|
+
</div>
|
|
440
|
+
<div class="item">
|
|
441
|
+
<span class="item-label">Losers</span>
|
|
442
|
+
<span id="summary-losers" class="item-value pnl-negative">${s.losers}</span>
|
|
443
|
+
</div>
|
|
444
|
+
<div class="item">
|
|
445
|
+
<span class="item-label">Win Rate</span>
|
|
446
|
+
<span id="summary-winrate" class="item-value">${winRate}</span>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
<table class="positions-table">
|
|
450
|
+
<thead>
|
|
451
|
+
<tr>
|
|
452
|
+
<th>#</th><th>Symbol</th><th>Side</th><th>Qty</th>
|
|
453
|
+
<th>Entry Time</th><th>Exit Time</th><th>Avg Entry</th><th>Avg Exit</th><th>P&L</th>
|
|
454
|
+
</tr>
|
|
455
|
+
</thead>
|
|
456
|
+
<tbody id="positions-tbody"></tbody>
|
|
457
|
+
</table>
|
|
458
|
+
</div>
|
|
459
|
+
`;
|
|
460
|
+
} else {
|
|
461
|
+
positionsHtml = `
|
|
462
|
+
<div class="card" style="margin-top: 24px;">
|
|
463
|
+
<h3>Positions (Round-Trip Trades)</h3>
|
|
464
|
+
<p style="color: #8b949e;">No completed positions.</p>
|
|
465
|
+
</div>
|
|
466
|
+
`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
container.innerHTML = `
|
|
470
|
+
<a href="/" class="back-link">← Back to Runs</a>
|
|
471
|
+
<div class="run-header">
|
|
472
|
+
<h2>${r.strategy}</h2>
|
|
473
|
+
<div class="run-meta">
|
|
474
|
+
<span><span class="badge ${statusClass}">${r.status}</span></span>
|
|
475
|
+
<span>Created: ${createdAt}</span>
|
|
476
|
+
<span>Mode: ${r.mode}</span>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
<div class="stats-grid">
|
|
480
|
+
<div class="stat-card">
|
|
481
|
+
<div class="label">Symbols</div>
|
|
482
|
+
<div class="value">${r.symbols.length}</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="stat-card">
|
|
485
|
+
<div class="label">Bar Period</div>
|
|
486
|
+
<div class="value">${r.bar_period || '-'}</div>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="stat-card">
|
|
489
|
+
<div class="label">Total Bars</div>
|
|
490
|
+
<div class="value">${r.bar_count || 0}</div>
|
|
491
|
+
</div>
|
|
492
|
+
<div class="stat-card">
|
|
493
|
+
<div class="label">Date Range</div>
|
|
494
|
+
<div class="value" style="font-size: 14px;">${dateRange}</div>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="card">
|
|
498
|
+
<h3>Symbols</h3>
|
|
499
|
+
<p style="color: #8b949e; font-family: monospace;">${r.symbols.join(', ') || 'None'}</p>
|
|
500
|
+
</div>
|
|
501
|
+
${positionsHtml}
|
|
502
|
+
`;
|
|
503
|
+
if (allPositions.length > 0) {
|
|
504
|
+
renderPositions(allPositions, '');
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
document.addEventListener('DOMContentLoaded', loadRunDetail);
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@app.get("/run/{run_id}", response_class=HTMLResponse)
|
|
512
|
+
async def run_detail_page(run_id: str):
|
|
513
|
+
sidebar = SIDEBAR_HTML.format(
|
|
514
|
+
runs_active="active", backtest_active="", secmaster_active=""
|
|
515
|
+
)
|
|
516
|
+
return f"""
|
|
517
|
+
<!DOCTYPE html>
|
|
518
|
+
<html>
|
|
519
|
+
<head>
|
|
520
|
+
<title>Run Detail - OneSecondTrader</title>
|
|
521
|
+
<style>{BASE_STYLE}{RUN_DETAIL_STYLE}</style>
|
|
522
|
+
</head>
|
|
523
|
+
<body>
|
|
524
|
+
{sidebar}
|
|
525
|
+
<main class="main-content">
|
|
526
|
+
<div class="container">
|
|
527
|
+
<div id="run-content">
|
|
528
|
+
<p style="color: #8b949e;">Loading...</p>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
</main>
|
|
532
|
+
<script>{RUN_DETAIL_SCRIPT}</script>
|
|
533
|
+
</body>
|
|
534
|
+
</html>
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _get_runs_db_path() -> str:
|
|
539
|
+
return os.environ.get("RUNS_DB_PATH", "runs.db")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _get_recent_runs(limit: int = 50) -> list[dict]:
|
|
543
|
+
import json
|
|
544
|
+
|
|
545
|
+
db_path = _get_runs_db_path()
|
|
546
|
+
if not os.path.exists(db_path):
|
|
547
|
+
return []
|
|
548
|
+
|
|
549
|
+
conn = sqlite3.connect(db_path)
|
|
550
|
+
cursor = conn.cursor()
|
|
551
|
+
cursor.execute(
|
|
552
|
+
"""
|
|
553
|
+
SELECT
|
|
554
|
+
r.run_id,
|
|
555
|
+
r.strategy,
|
|
556
|
+
r.symbols,
|
|
557
|
+
r.bar_period,
|
|
558
|
+
r.mode,
|
|
559
|
+
r.status,
|
|
560
|
+
r.created_at,
|
|
561
|
+
r.completed_at,
|
|
562
|
+
COUNT(b.id) as bar_count,
|
|
563
|
+
MIN(b.ts_event) as first_bar,
|
|
564
|
+
MAX(b.ts_event) as last_bar
|
|
565
|
+
FROM runs r
|
|
566
|
+
LEFT JOIN bars b ON r.run_id = b.run_id
|
|
567
|
+
GROUP BY r.run_id
|
|
568
|
+
ORDER BY r.created_at DESC
|
|
569
|
+
LIMIT ?
|
|
570
|
+
""",
|
|
571
|
+
(limit,),
|
|
572
|
+
)
|
|
573
|
+
runs = []
|
|
574
|
+
for row in cursor.fetchall():
|
|
575
|
+
runs.append(
|
|
576
|
+
{
|
|
577
|
+
"run_id": row[0],
|
|
578
|
+
"strategy": row[1],
|
|
579
|
+
"symbols": json.loads(row[2]) if row[2] else [],
|
|
580
|
+
"bar_period": _normalize_bar_period(row[3]),
|
|
581
|
+
"mode": row[4],
|
|
582
|
+
"status": row[5],
|
|
583
|
+
"created_at": row[6],
|
|
584
|
+
"completed_at": row[7],
|
|
585
|
+
"bar_count": row[8],
|
|
586
|
+
"first_bar": row[9],
|
|
587
|
+
"last_bar": row[10],
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
conn.close()
|
|
591
|
+
return runs
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@app.get("/api/runs")
|
|
595
|
+
async def list_runs(limit: int = 50):
|
|
596
|
+
return {"runs": _get_recent_runs(limit)}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _get_run_by_id(run_id: str) -> dict | None:
|
|
600
|
+
import json
|
|
601
|
+
|
|
602
|
+
db_path = _get_runs_db_path()
|
|
603
|
+
if not os.path.exists(db_path):
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
conn = sqlite3.connect(db_path)
|
|
607
|
+
cursor = conn.cursor()
|
|
608
|
+
cursor.execute(
|
|
609
|
+
"""
|
|
610
|
+
SELECT
|
|
611
|
+
r.run_id,
|
|
612
|
+
r.strategy,
|
|
613
|
+
r.symbols,
|
|
614
|
+
r.bar_period,
|
|
615
|
+
r.mode,
|
|
616
|
+
r.status,
|
|
617
|
+
r.created_at,
|
|
618
|
+
r.completed_at,
|
|
619
|
+
COUNT(b.id) as bar_count,
|
|
620
|
+
MIN(b.ts_event) as first_bar,
|
|
621
|
+
MAX(b.ts_event) as last_bar
|
|
622
|
+
FROM runs r
|
|
623
|
+
LEFT JOIN bars b ON r.run_id = b.run_id
|
|
624
|
+
WHERE r.run_id = ?
|
|
625
|
+
GROUP BY r.run_id
|
|
626
|
+
""",
|
|
627
|
+
(run_id,),
|
|
628
|
+
)
|
|
629
|
+
row = cursor.fetchone()
|
|
630
|
+
conn.close()
|
|
631
|
+
if not row:
|
|
632
|
+
return None
|
|
633
|
+
return {
|
|
634
|
+
"run_id": row[0],
|
|
635
|
+
"strategy": row[1],
|
|
636
|
+
"symbols": json.loads(row[2]) if row[2] else [],
|
|
637
|
+
"bar_period": _normalize_bar_period(row[3]),
|
|
638
|
+
"mode": row[4],
|
|
639
|
+
"status": row[5],
|
|
640
|
+
"created_at": row[6],
|
|
641
|
+
"completed_at": row[7],
|
|
642
|
+
"bar_count": row[8],
|
|
643
|
+
"first_bar": row[9],
|
|
644
|
+
"last_bar": row[10],
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@app.get("/api/run/{run_id}")
|
|
649
|
+
async def get_run(run_id: str):
|
|
650
|
+
run = _get_run_by_id(run_id)
|
|
651
|
+
if not run:
|
|
652
|
+
from fastapi import HTTPException
|
|
653
|
+
|
|
654
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
655
|
+
return run
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@app.delete("/api/run/{run_id}")
|
|
659
|
+
async def delete_run(run_id: str):
|
|
660
|
+
db_path = _get_runs_db_path()
|
|
661
|
+
if not os.path.exists(db_path):
|
|
662
|
+
from fastapi import HTTPException
|
|
663
|
+
|
|
664
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
665
|
+
conn = sqlite3.connect(db_path)
|
|
666
|
+
cursor = conn.cursor()
|
|
667
|
+
cursor.execute("DELETE FROM bars WHERE run_id = ?", (run_id,))
|
|
668
|
+
cursor.execute("DELETE FROM order_requests WHERE run_id = ?", (run_id,))
|
|
669
|
+
cursor.execute("DELETE FROM order_responses WHERE run_id = ?", (run_id,))
|
|
670
|
+
cursor.execute("DELETE FROM fills WHERE run_id = ?", (run_id,))
|
|
671
|
+
cursor.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
|
672
|
+
conn.commit()
|
|
673
|
+
conn.close()
|
|
674
|
+
return {"status": "deleted"}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _normalize_side(side) -> str:
|
|
678
|
+
if side in ("BUY", 1, "1"):
|
|
679
|
+
return "BUY"
|
|
680
|
+
return "SELL"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _extract_positions(fills: list[dict]) -> list[dict]:
|
|
684
|
+
if not fills:
|
|
685
|
+
return []
|
|
686
|
+
for f in fills:
|
|
687
|
+
f["side"] = _normalize_side(f["side"])
|
|
688
|
+
positions = []
|
|
689
|
+
by_symbol: dict[str, list[dict]] = {}
|
|
690
|
+
for f in fills:
|
|
691
|
+
by_symbol.setdefault(f["symbol"], []).append(f)
|
|
692
|
+
for symbol, symbol_fills in by_symbol.items():
|
|
693
|
+
symbol_fills.sort(key=lambda x: x["ts_event"])
|
|
694
|
+
position = 0.0
|
|
695
|
+
position_fills: list[dict] = []
|
|
696
|
+
position_id = 0
|
|
697
|
+
for fill in symbol_fills:
|
|
698
|
+
qty = fill["quantity"]
|
|
699
|
+
signed_qty = qty if fill["side"] == "BUY" else -qty
|
|
700
|
+
new_position = position + signed_qty
|
|
701
|
+
position_fills.append(fill)
|
|
702
|
+
if new_position == 0.0 and position != 0.0:
|
|
703
|
+
position_id += 1
|
|
704
|
+
pnl = 0.0
|
|
705
|
+
total_commission = 0.0
|
|
706
|
+
for pf in position_fills:
|
|
707
|
+
value = pf["price"] * pf["quantity"]
|
|
708
|
+
commission = pf.get("commission") or 0.0
|
|
709
|
+
total_commission += commission
|
|
710
|
+
if pf["side"] == "BUY":
|
|
711
|
+
pnl -= value
|
|
712
|
+
else:
|
|
713
|
+
pnl += value
|
|
714
|
+
pnl -= total_commission
|
|
715
|
+
entry_fill = position_fills[0]
|
|
716
|
+
exit_fill = position_fills[-1]
|
|
717
|
+
entry_side = entry_fill["side"]
|
|
718
|
+
entry_qty = sum(
|
|
719
|
+
pf["quantity"] for pf in position_fills if pf["side"] == entry_side
|
|
720
|
+
)
|
|
721
|
+
entry_value = sum(
|
|
722
|
+
pf["quantity"] * pf["price"]
|
|
723
|
+
for pf in position_fills
|
|
724
|
+
if pf["side"] == entry_side
|
|
725
|
+
)
|
|
726
|
+
avg_entry = entry_value / entry_qty if entry_qty else 0
|
|
727
|
+
exit_side = "SELL" if entry_side == "BUY" else "BUY"
|
|
728
|
+
exit_qty = sum(
|
|
729
|
+
pf["quantity"] for pf in position_fills if pf["side"] == exit_side
|
|
730
|
+
)
|
|
731
|
+
exit_value = sum(
|
|
732
|
+
pf["quantity"] * pf["price"]
|
|
733
|
+
for pf in position_fills
|
|
734
|
+
if pf["side"] == exit_side
|
|
735
|
+
)
|
|
736
|
+
avg_exit = exit_value / exit_qty if exit_qty else 0
|
|
737
|
+
positions.append(
|
|
738
|
+
{
|
|
739
|
+
"position_id": position_id,
|
|
740
|
+
"symbol": symbol,
|
|
741
|
+
"side": "LONG" if entry_side == "BUY" else "SHORT",
|
|
742
|
+
"quantity": entry_qty,
|
|
743
|
+
"entry_time": entry_fill["ts_event"],
|
|
744
|
+
"exit_time": exit_fill["ts_event"],
|
|
745
|
+
"avg_entry_price": avg_entry,
|
|
746
|
+
"avg_exit_price": avg_exit,
|
|
747
|
+
"pnl": pnl,
|
|
748
|
+
"commission": total_commission,
|
|
749
|
+
"num_fills": len(position_fills),
|
|
750
|
+
}
|
|
751
|
+
)
|
|
752
|
+
position_fills = []
|
|
753
|
+
position = new_position
|
|
754
|
+
positions.sort(key=lambda x: x["entry_time"])
|
|
755
|
+
return positions
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@app.get("/api/run/{run_id}/positions")
|
|
759
|
+
async def get_run_positions(run_id: str):
|
|
760
|
+
db_path = _get_runs_db_path()
|
|
761
|
+
if not os.path.exists(db_path):
|
|
762
|
+
return {"positions": []}
|
|
763
|
+
conn = sqlite3.connect(db_path)
|
|
764
|
+
cursor = conn.cursor()
|
|
765
|
+
cursor.execute(
|
|
766
|
+
"""
|
|
767
|
+
SELECT ts_event, symbol, side, quantity, price, commission
|
|
768
|
+
FROM fills WHERE run_id = ? ORDER BY ts_event
|
|
769
|
+
""",
|
|
770
|
+
(run_id,),
|
|
771
|
+
)
|
|
772
|
+
rows = cursor.fetchall()
|
|
773
|
+
conn.close()
|
|
774
|
+
fills = [
|
|
775
|
+
{
|
|
776
|
+
"ts_event": row[0],
|
|
777
|
+
"symbol": row[1],
|
|
778
|
+
"side": row[2],
|
|
779
|
+
"quantity": row[3],
|
|
780
|
+
"price": row[4],
|
|
781
|
+
"commission": row[5],
|
|
782
|
+
}
|
|
783
|
+
for row in rows
|
|
784
|
+
]
|
|
785
|
+
positions = _extract_positions(fills)
|
|
786
|
+
total_pnl = sum(p["pnl"] for p in positions)
|
|
787
|
+
winners = sum(1 for p in positions if p["pnl"] > 0)
|
|
788
|
+
losers = sum(1 for p in positions if p["pnl"] < 0)
|
|
789
|
+
return {
|
|
790
|
+
"positions": positions,
|
|
791
|
+
"summary": {
|
|
792
|
+
"total_positions": len(positions),
|
|
793
|
+
"total_pnl": total_pnl,
|
|
794
|
+
"winners": winners,
|
|
795
|
+
"losers": losers,
|
|
796
|
+
"win_rate": winners / len(positions) if positions else 0,
|
|
797
|
+
},
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@app.get("/health")
|
|
802
|
+
async def health():
|
|
803
|
+
return {"status": "ok"}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@app.get("/api/strategies")
|
|
807
|
+
async def list_strategies():
|
|
808
|
+
strategies = []
|
|
809
|
+
for class_name, cls in registry.get_strategies().items():
|
|
810
|
+
display_name = getattr(cls, "name", "") or class_name
|
|
811
|
+
strategies.append({"class_name": class_name, "display_name": display_name})
|
|
812
|
+
return {"strategies": strategies}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@app.get("/api/brokers")
|
|
816
|
+
async def list_brokers():
|
|
817
|
+
return {"brokers": list(registry.get_brokers().keys())}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@app.get("/api/datafeeds")
|
|
821
|
+
async def list_datafeeds():
|
|
822
|
+
return {"datafeeds": list(registry.get_datafeeds().keys())}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@app.get("/api/strategies/{name}")
|
|
826
|
+
async def get_strategy(name: str):
|
|
827
|
+
schema = registry.get_strategy_schema(name)
|
|
828
|
+
if schema is None:
|
|
829
|
+
return {"error": "Strategy not found"}
|
|
830
|
+
return schema
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@app.get("/api/brokers/{name}")
|
|
834
|
+
async def get_broker(name: str):
|
|
835
|
+
schema = registry.get_broker_schema(name)
|
|
836
|
+
if schema is None:
|
|
837
|
+
return {"error": "Broker not found"}
|
|
838
|
+
return schema
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@app.get("/api/datafeeds/{name}")
|
|
842
|
+
async def get_datafeed(name: str):
|
|
843
|
+
schema = registry.get_datafeed_schema(name)
|
|
844
|
+
if schema is None:
|
|
845
|
+
return {"error": "Datafeed not found"}
|
|
846
|
+
return schema
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
@app.get("/api/secmaster/symbols")
|
|
850
|
+
async def get_symbols(bar_period: str | None = None):
|
|
851
|
+
return {"symbols": _get_available_symbols(bar_period)}
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@app.get("/api/secmaster/symbols_coverage")
|
|
855
|
+
async def get_symbols_coverage(bar_period: str):
|
|
856
|
+
return {"symbols": _get_symbols_with_coverage(bar_period)}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@app.get("/api/secmaster/bar_periods")
|
|
860
|
+
async def get_bar_periods():
|
|
861
|
+
return {"bar_periods": _get_available_bar_periods()}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@app.get("/api/presets")
|
|
865
|
+
async def list_presets():
|
|
866
|
+
db_path = _get_secmaster_path()
|
|
867
|
+
if not os.path.exists(db_path):
|
|
868
|
+
return {"presets": []}
|
|
869
|
+
conn = sqlite3.connect(db_path)
|
|
870
|
+
cursor = conn.cursor()
|
|
871
|
+
cursor.execute("SELECT name FROM symbol_presets ORDER BY name")
|
|
872
|
+
presets = [row[0] for row in cursor.fetchall()]
|
|
873
|
+
conn.close()
|
|
874
|
+
return {"presets": presets}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@app.get("/api/presets/{name}")
|
|
878
|
+
async def get_preset(name: str):
|
|
879
|
+
import json
|
|
880
|
+
|
|
881
|
+
db_path = _get_secmaster_path()
|
|
882
|
+
if not os.path.exists(db_path):
|
|
883
|
+
return {"error": "Preset not found"}
|
|
884
|
+
conn = sqlite3.connect(db_path)
|
|
885
|
+
cursor = conn.cursor()
|
|
886
|
+
cursor.execute("SELECT symbols FROM symbol_presets WHERE name = ?", (name,))
|
|
887
|
+
row = cursor.fetchone()
|
|
888
|
+
conn.close()
|
|
889
|
+
if row is None:
|
|
890
|
+
return {"error": "Preset not found"}
|
|
891
|
+
return {"name": name, "symbols": json.loads(row[0])}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
class PresetRequest(BaseModel):
|
|
895
|
+
name: str
|
|
896
|
+
symbols: list[str]
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@app.post("/api/presets")
|
|
900
|
+
async def create_preset(request: PresetRequest):
|
|
901
|
+
import json
|
|
902
|
+
|
|
903
|
+
db_path = _get_secmaster_path()
|
|
904
|
+
conn = sqlite3.connect(db_path)
|
|
905
|
+
cursor = conn.cursor()
|
|
906
|
+
cursor.execute(
|
|
907
|
+
"INSERT INTO symbol_presets (name, symbols) VALUES (?, ?)",
|
|
908
|
+
(request.name, json.dumps(request.symbols)),
|
|
909
|
+
)
|
|
910
|
+
conn.commit()
|
|
911
|
+
conn.close()
|
|
912
|
+
return {"status": "created", "name": request.name}
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@app.put("/api/presets/{name}")
|
|
916
|
+
async def update_preset(name: str, request: PresetRequest):
|
|
917
|
+
import json
|
|
918
|
+
|
|
919
|
+
db_path = _get_secmaster_path()
|
|
920
|
+
conn = sqlite3.connect(db_path)
|
|
921
|
+
cursor = conn.cursor()
|
|
922
|
+
cursor.execute(
|
|
923
|
+
"UPDATE symbol_presets SET symbols = ? WHERE name = ?",
|
|
924
|
+
(json.dumps(request.symbols), name),
|
|
925
|
+
)
|
|
926
|
+
conn.commit()
|
|
927
|
+
conn.close()
|
|
928
|
+
return {"status": "updated", "name": name}
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
@app.delete("/api/presets/{name}")
|
|
932
|
+
async def delete_preset(name: str):
|
|
933
|
+
db_path = _get_secmaster_path()
|
|
934
|
+
conn = sqlite3.connect(db_path)
|
|
935
|
+
cursor = conn.cursor()
|
|
936
|
+
cursor.execute("DELETE FROM symbol_presets WHERE name = ?", (name,))
|
|
937
|
+
conn.commit()
|
|
938
|
+
conn.close()
|
|
939
|
+
return {"status": "deleted", "name": name}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
class BacktestRequest(BaseModel):
|
|
943
|
+
strategy: str
|
|
944
|
+
strategy_params: dict
|
|
945
|
+
symbols: list[str]
|
|
946
|
+
start_date: str | None = None
|
|
947
|
+
end_date: str | None = None
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _run_backtest(request: BacktestRequest, run_id: str) -> None:
|
|
951
|
+
import pandas as pd
|
|
952
|
+
|
|
953
|
+
try:
|
|
954
|
+
_running_jobs[run_id] = "running"
|
|
955
|
+
|
|
956
|
+
strategy_cls = registry.get_strategies().get(request.strategy)
|
|
957
|
+
if not strategy_cls:
|
|
958
|
+
_running_jobs[run_id] = "error: invalid strategy"
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
strategy_params = _deserialize_params(
|
|
962
|
+
request.strategy_params, getattr(strategy_cls, "parameters", {})
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
updated_parameters = {}
|
|
966
|
+
for name, spec in strategy_cls.parameters.items():
|
|
967
|
+
if name in strategy_params:
|
|
968
|
+
updated_parameters[name] = type(spec)(
|
|
969
|
+
default=strategy_params[name],
|
|
970
|
+
**{k: v for k, v in spec.__dict__.items() if k != "default"},
|
|
971
|
+
)
|
|
972
|
+
else:
|
|
973
|
+
updated_parameters[name] = spec
|
|
974
|
+
|
|
975
|
+
configured_strategy = type(
|
|
976
|
+
f"Configured{request.strategy}",
|
|
977
|
+
(strategy_cls,),
|
|
978
|
+
{"symbols": request.symbols, "parameters": updated_parameters},
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
datafeed_attrs = {}
|
|
982
|
+
if request.start_date:
|
|
983
|
+
datafeed_attrs["start_ts"] = int(
|
|
984
|
+
pd.Timestamp(request.start_date, tz="UTC").value
|
|
985
|
+
)
|
|
986
|
+
if request.end_date:
|
|
987
|
+
end_dt = (
|
|
988
|
+
pd.Timestamp(request.end_date, tz="UTC")
|
|
989
|
+
+ pd.Timedelta(days=1)
|
|
990
|
+
- pd.Timedelta(1, unit="ns")
|
|
991
|
+
)
|
|
992
|
+
datafeed_attrs["end_ts"] = int(end_dt.value)
|
|
993
|
+
|
|
994
|
+
configured_datafeed = type(
|
|
995
|
+
"ConfiguredSimulatedDatafeed",
|
|
996
|
+
(SimulatedDatafeed,),
|
|
997
|
+
datafeed_attrs,
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
configured_orchestrator = type(
|
|
1001
|
+
"ConfiguredOrchestrator",
|
|
1002
|
+
(Orchestrator,),
|
|
1003
|
+
{"mode": "backtest"},
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
orchestrator = configured_orchestrator(
|
|
1007
|
+
strategies=[configured_strategy],
|
|
1008
|
+
broker=SimulatedBroker,
|
|
1009
|
+
datafeed=configured_datafeed,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
orchestrator.run()
|
|
1013
|
+
_running_jobs[run_id] = "completed"
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
_running_jobs[run_id] = f"error: {e}"
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _deserialize_params(params: dict, param_specs: dict) -> dict:
|
|
1019
|
+
result = {}
|
|
1020
|
+
for name, value in params.items():
|
|
1021
|
+
spec = param_specs.get(name)
|
|
1022
|
+
if spec is None:
|
|
1023
|
+
result[name] = value
|
|
1024
|
+
continue
|
|
1025
|
+
if isinstance(spec.default, enum.Enum):
|
|
1026
|
+
enum_cls = type(spec.default)
|
|
1027
|
+
result[name] = enum_cls[value]
|
|
1028
|
+
else:
|
|
1029
|
+
result[name] = value
|
|
1030
|
+
return result
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@app.post("/api/backtest/run")
|
|
1034
|
+
async def run_backtest(request: BacktestRequest, background_tasks: BackgroundTasks):
|
|
1035
|
+
import uuid
|
|
1036
|
+
|
|
1037
|
+
run_id = str(uuid.uuid4())[:8]
|
|
1038
|
+
background_tasks.add_task(_run_backtest, request, run_id)
|
|
1039
|
+
return {"run_id": run_id, "status": "started"}
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@app.get("/api/backtest/status/{run_id}")
|
|
1043
|
+
async def backtest_status(run_id: str):
|
|
1044
|
+
status = _running_jobs.get(run_id, "not found")
|
|
1045
|
+
return {"run_id": run_id, "status": status}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
BACKTEST_FORM_STYLE = """
|
|
1049
|
+
.form-group { margin-bottom: 16px; }
|
|
1050
|
+
.form-group label { display: block; margin-bottom: 6px; font-size: 14px; color: #8b949e; }
|
|
1051
|
+
.form-group select, .form-group input {
|
|
1052
|
+
width: 100%; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d;
|
|
1053
|
+
border-radius: 6px; color: #e6edf3; font-size: 14px;
|
|
1054
|
+
}
|
|
1055
|
+
.form-group select:focus, .form-group input:focus { outline: none; border-color: #58a6ff; }
|
|
1056
|
+
.params-container { margin-top: 12px; padding: 12px; background: #0d1117; border-radius: 6px; }
|
|
1057
|
+
.param-row { display: flex; gap: 12px; margin-bottom: 8px; align-items: center; }
|
|
1058
|
+
.param-row label { min-width: 120px; font-size: 13px; }
|
|
1059
|
+
.param-row input, .param-row select { flex: 1; }
|
|
1060
|
+
.btn { padding: 10px 20px; background: #238636; border: none; border-radius: 6px;
|
|
1061
|
+
color: #fff; font-size: 14px; cursor: pointer; }
|
|
1062
|
+
.btn:hover { background: #2ea043; }
|
|
1063
|
+
.btn:disabled { background: #30363d; cursor: not-allowed; }
|
|
1064
|
+
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
|
1065
|
+
.btn-secondary { background: #30363d; }
|
|
1066
|
+
.btn-secondary:hover { background: #484f58; }
|
|
1067
|
+
.btn-danger { background: #da3633; }
|
|
1068
|
+
.btn-danger:hover { background: #f85149; }
|
|
1069
|
+
#status { margin-top: 16px; padding: 12px; border-radius: 6px; display: none; }
|
|
1070
|
+
.status-running { background: #9e6a03; }
|
|
1071
|
+
.status-completed { background: #238636; }
|
|
1072
|
+
.status-error { background: #da3633; }
|
|
1073
|
+
.date-row { display: flex; gap: 12px; }
|
|
1074
|
+
.date-row .form-group { flex: 1; margin-bottom: 0; }
|
|
1075
|
+
.symbol-section { background: #0d1117; border-radius: 6px; padding: 12px; }
|
|
1076
|
+
.preset-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
|
1077
|
+
.preset-row select { flex: 1; }
|
|
1078
|
+
.preset-row input { flex: 1; }
|
|
1079
|
+
.search-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
|
1080
|
+
.search-row input { flex: 1; }
|
|
1081
|
+
.search-results { max-height: 150px; overflow-y: auto; border: 1px solid #30363d; border-radius: 4px; margin-bottom: 12px; }
|
|
1082
|
+
.search-results:empty { display: none; }
|
|
1083
|
+
.search-result { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid #21262d; }
|
|
1084
|
+
.search-result:last-child { border-bottom: none; }
|
|
1085
|
+
.search-result:hover { background: #161b22; }
|
|
1086
|
+
.search-result .symbol { font-family: monospace; }
|
|
1087
|
+
.selected-symbols { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; max-height: 150px; overflow-y: auto; }
|
|
1088
|
+
.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; }
|
|
1089
|
+
.selected-tag .remove { cursor: pointer; opacity: 0.7; }
|
|
1090
|
+
.selected-tag .remove:hover { opacity: 1; }
|
|
1091
|
+
.selected-label { font-size: 13px; color: #8b949e; margin-bottom: 6px; }
|
|
1092
|
+
.section-header { font-size: 14px; color: #8b949e; margin-bottom: 6px; margin-top: 8px; }
|
|
1093
|
+
"""
|
|
1094
|
+
|
|
1095
|
+
BACKTEST_FORM_SCRIPT = """
|
|
1096
|
+
let strategyParams = [];
|
|
1097
|
+
let symbolCoverage = {};
|
|
1098
|
+
let allSymbols = [];
|
|
1099
|
+
let selectedSymbols = [];
|
|
1100
|
+
let presets = [];
|
|
1101
|
+
let barPeriods = [];
|
|
1102
|
+
let currentBarPeriod = '';
|
|
1103
|
+
let globalMinTs = null;
|
|
1104
|
+
let globalMaxTs = null;
|
|
1105
|
+
let globalMinDate = null;
|
|
1106
|
+
let globalMaxDate = null;
|
|
1107
|
+
|
|
1108
|
+
async function loadStrategies() {
|
|
1109
|
+
const res = await fetch('/api/strategies');
|
|
1110
|
+
const data = await res.json();
|
|
1111
|
+
const sel = document.getElementById('strategy');
|
|
1112
|
+
data.strategies.forEach(s => sel.innerHTML += `<option value="${s.class_name}">${s.display_name}</option>`);
|
|
1113
|
+
if (data.strategies.length > 0) { sel.value = data.strategies[0].class_name; loadStrategyParams(); }
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function loadBarPeriods() {
|
|
1117
|
+
const res = await fetch('/api/secmaster/bar_periods');
|
|
1118
|
+
const data = await res.json();
|
|
1119
|
+
barPeriods = data.bar_periods || [];
|
|
1120
|
+
const sel = document.getElementById('bar-period');
|
|
1121
|
+
sel.innerHTML = '';
|
|
1122
|
+
barPeriods.forEach(p => sel.innerHTML += `<option value="${p}">${p}</option>`);
|
|
1123
|
+
if (barPeriods.length > 0) {
|
|
1124
|
+
currentBarPeriod = barPeriods[0];
|
|
1125
|
+
sel.value = currentBarPeriod;
|
|
1126
|
+
await loadSymbolsForPeriod();
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function onBarPeriodChange() {
|
|
1131
|
+
currentBarPeriod = document.getElementById('bar-period').value;
|
|
1132
|
+
selectedSymbols = [];
|
|
1133
|
+
renderSelectedSymbols();
|
|
1134
|
+
await loadSymbolsForPeriod();
|
|
1135
|
+
updateDateRange();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function loadSymbolsForPeriod() {
|
|
1139
|
+
const res = await fetch(`/api/secmaster/symbols_coverage?bar_period=${currentBarPeriod}`);
|
|
1140
|
+
const data = await res.json();
|
|
1141
|
+
symbolCoverage = {};
|
|
1142
|
+
allSymbols = [];
|
|
1143
|
+
(data.symbols || []).forEach(s => {
|
|
1144
|
+
symbolCoverage[s.symbol] = {min_ts: s.min_ts, max_ts: s.max_ts};
|
|
1145
|
+
allSymbols.push(s.symbol);
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function loadPresets() {
|
|
1150
|
+
const res = await fetch('/api/presets');
|
|
1151
|
+
const data = await res.json();
|
|
1152
|
+
presets = data.presets || [];
|
|
1153
|
+
renderPresetSelect();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function renderPresetSelect() {
|
|
1157
|
+
const sel = document.getElementById('preset-select');
|
|
1158
|
+
sel.innerHTML = '<option value="">-- Select Preset --</option>';
|
|
1159
|
+
presets.forEach(p => sel.innerHTML += `<option value="${p}">${p}</option>`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function loadPreset() {
|
|
1163
|
+
const name = document.getElementById('preset-select').value;
|
|
1164
|
+
if (!name) return;
|
|
1165
|
+
const res = await fetch(`/api/presets/${encodeURIComponent(name)}`);
|
|
1166
|
+
const data = await res.json();
|
|
1167
|
+
if (data.symbols) {
|
|
1168
|
+
selectedSymbols = data.symbols.filter(s => allSymbols.includes(s));
|
|
1169
|
+
renderSelectedSymbols();
|
|
1170
|
+
updateDateRange();
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function savePreset() {
|
|
1175
|
+
const nameInput = document.getElementById('preset-name');
|
|
1176
|
+
const name = nameInput.value.trim();
|
|
1177
|
+
if (!name) { alert('Enter a preset name'); return; }
|
|
1178
|
+
if (selectedSymbols.length === 0) { alert('Select at least one symbol'); return; }
|
|
1179
|
+
const exists = presets.includes(name);
|
|
1180
|
+
const method = exists ? 'PUT' : 'POST';
|
|
1181
|
+
const url = exists ? `/api/presets/${encodeURIComponent(name)}` : '/api/presets';
|
|
1182
|
+
await fetch(url, {
|
|
1183
|
+
method, headers: {'Content-Type': 'application/json'},
|
|
1184
|
+
body: JSON.stringify({name, symbols: selectedSymbols})
|
|
1185
|
+
});
|
|
1186
|
+
nameInput.value = '';
|
|
1187
|
+
await loadPresets();
|
|
1188
|
+
document.getElementById('preset-select').value = name;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function deletePreset() {
|
|
1192
|
+
const name = document.getElementById('preset-select').value;
|
|
1193
|
+
if (!name) { alert('Select a preset to delete'); return; }
|
|
1194
|
+
if (!confirm(`Delete preset "${name}"?`)) return;
|
|
1195
|
+
await fetch(`/api/presets/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
|
1196
|
+
await loadPresets();
|
|
1197
|
+
selectedSymbols = [];
|
|
1198
|
+
renderSelectedSymbols();
|
|
1199
|
+
updateDateRange();
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function searchSymbols() {
|
|
1203
|
+
const query = document.getElementById('symbol-search').value.toUpperCase();
|
|
1204
|
+
const container = document.getElementById('search-results');
|
|
1205
|
+
if (query.length < 1) { container.innerHTML = ''; return; }
|
|
1206
|
+
const matches = allSymbols.filter(s => s.toUpperCase().includes(query) && !selectedSymbols.includes(s)).slice(0, 20);
|
|
1207
|
+
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('');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function addSymbol(symbol) {
|
|
1211
|
+
if (!selectedSymbols.includes(symbol)) {
|
|
1212
|
+
selectedSymbols.push(symbol);
|
|
1213
|
+
selectedSymbols.sort();
|
|
1214
|
+
renderSelectedSymbols();
|
|
1215
|
+
searchSymbols();
|
|
1216
|
+
updateDateRange();
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function removeSymbol(symbol) {
|
|
1221
|
+
selectedSymbols = selectedSymbols.filter(s => s !== symbol);
|
|
1222
|
+
renderSelectedSymbols();
|
|
1223
|
+
searchSymbols();
|
|
1224
|
+
updateDateRange();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function renderSelectedSymbols() {
|
|
1228
|
+
const container = document.getElementById('selected-symbols');
|
|
1229
|
+
const label = document.getElementById('selected-label');
|
|
1230
|
+
label.textContent = `Selected (${selectedSymbols.length}):`;
|
|
1231
|
+
container.innerHTML = selectedSymbols.map(s => `<span class="selected-tag">${s}<span class="remove" onclick="removeSymbol('${s}')">×</span></span>`).join('');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function updateDateRange() {
|
|
1235
|
+
if (selectedSymbols.length === 0) {
|
|
1236
|
+
globalMinTs = null;
|
|
1237
|
+
globalMaxTs = null;
|
|
1238
|
+
document.getElementById('start-date').value = '';
|
|
1239
|
+
document.getElementById('end-date').value = '';
|
|
1240
|
+
document.getElementById('range-slider-container').style.display = 'none';
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
let minTs = Infinity, maxTs = -Infinity;
|
|
1244
|
+
selectedSymbols.forEach(s => {
|
|
1245
|
+
const cov = symbolCoverage[s];
|
|
1246
|
+
if (cov) {
|
|
1247
|
+
if (cov.min_ts < minTs) minTs = cov.min_ts;
|
|
1248
|
+
if (cov.max_ts > maxTs) maxTs = cov.max_ts;
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
if (minTs === Infinity) return;
|
|
1252
|
+
globalMinTs = minTs;
|
|
1253
|
+
globalMaxTs = maxTs;
|
|
1254
|
+
globalMinDate = tsToDate(minTs);
|
|
1255
|
+
globalMaxDate = tsToDate(maxTs);
|
|
1256
|
+
const startInput = document.getElementById('start-date');
|
|
1257
|
+
const endInput = document.getElementById('end-date');
|
|
1258
|
+
startInput.min = globalMinDate;
|
|
1259
|
+
startInput.max = globalMaxDate;
|
|
1260
|
+
startInput.value = globalMinDate;
|
|
1261
|
+
endInput.min = globalMinDate;
|
|
1262
|
+
endInput.max = globalMaxDate;
|
|
1263
|
+
endInput.value = globalMaxDate;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function tsToDate(ts) {
|
|
1267
|
+
return new Date(ts / 1000000).toISOString().split('T')[0];
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function clampDate(inputId) {
|
|
1271
|
+
if (!globalMinDate || !globalMaxDate) return;
|
|
1272
|
+
const input = document.getElementById(inputId);
|
|
1273
|
+
if (input.value < globalMinDate) input.value = globalMinDate;
|
|
1274
|
+
if (input.value > globalMaxDate) input.value = globalMaxDate;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function loadStrategyParams() {
|
|
1278
|
+
const name = document.getElementById('strategy').value;
|
|
1279
|
+
const res = await fetch(`/api/strategies/${name}`);
|
|
1280
|
+
const data = await res.json();
|
|
1281
|
+
strategyParams = (data.parameters || []).filter(p => p.name !== 'bar_period');
|
|
1282
|
+
renderParams('strategy-params', strategyParams, 'sp_');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function formatParamName(name) {
|
|
1286
|
+
return name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function renderParams(containerId, params, prefix) {
|
|
1290
|
+
const container = document.getElementById(containerId);
|
|
1291
|
+
container.innerHTML = '';
|
|
1292
|
+
params.forEach(p => {
|
|
1293
|
+
const row = document.createElement('div');
|
|
1294
|
+
row.className = 'param-row';
|
|
1295
|
+
let input;
|
|
1296
|
+
if (p.choices) {
|
|
1297
|
+
input = `<select id="${prefix}${p.name}">` +
|
|
1298
|
+
p.choices.map(c => `<option value="${c}" ${c===p.default?'selected':''}>${c}</option>`).join('') +
|
|
1299
|
+
'</select>';
|
|
1300
|
+
} else if (p.type === 'bool') {
|
|
1301
|
+
input = `<select id="${prefix}${p.name}"><option value="true" ${p.default?'selected':''}>true</option><option value="false" ${!p.default?'selected':''}>false</option></select>`;
|
|
1302
|
+
} else {
|
|
1303
|
+
const attrs = [];
|
|
1304
|
+
if (p.min !== undefined) attrs.push(`min="${p.min}"`);
|
|
1305
|
+
if (p.max !== undefined) attrs.push(`max="${p.max}"`);
|
|
1306
|
+
if (p.step !== undefined) attrs.push(`step="${p.step}"`);
|
|
1307
|
+
input = `<input type="${p.type==='int'||p.type==='float'?'number':'text'}" id="${prefix}${p.name}" value="${p.default}" ${attrs.join(' ')}>`;
|
|
1308
|
+
}
|
|
1309
|
+
row.innerHTML = `<label>${formatParamName(p.name)}</label>${input}`;
|
|
1310
|
+
container.appendChild(row);
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function collectParams(params, prefix) {
|
|
1315
|
+
const result = {};
|
|
1316
|
+
params.forEach(p => {
|
|
1317
|
+
const el = document.getElementById(prefix + p.name);
|
|
1318
|
+
let val = el.value;
|
|
1319
|
+
if (p.type === 'int') val = parseInt(val);
|
|
1320
|
+
else if (p.type === 'float') val = parseFloat(val);
|
|
1321
|
+
else if (p.type === 'bool') val = val === 'true';
|
|
1322
|
+
result[p.name] = val;
|
|
1323
|
+
});
|
|
1324
|
+
return result;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function runBacktest() {
|
|
1328
|
+
const btn = document.getElementById('run-btn');
|
|
1329
|
+
const status = document.getElementById('status');
|
|
1330
|
+
if (selectedSymbols.length === 0) {
|
|
1331
|
+
alert('Please select at least one symbol');
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
btn.disabled = true;
|
|
1335
|
+
status.style.display = 'block';
|
|
1336
|
+
status.className = 'status-running';
|
|
1337
|
+
status.textContent = 'Starting backtest...';
|
|
1338
|
+
|
|
1339
|
+
const params = collectParams(strategyParams, 'sp_');
|
|
1340
|
+
params.bar_period = currentBarPeriod;
|
|
1341
|
+
const payload = {
|
|
1342
|
+
strategy: document.getElementById('strategy').value,
|
|
1343
|
+
strategy_params: params,
|
|
1344
|
+
symbols: selectedSymbols,
|
|
1345
|
+
start_date: document.getElementById('start-date').value || null,
|
|
1346
|
+
end_date: document.getElementById('end-date').value || null
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
const res = await fetch('/api/backtest/run', {
|
|
1350
|
+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
|
1351
|
+
});
|
|
1352
|
+
const data = await res.json();
|
|
1353
|
+
const runId = data.run_id;
|
|
1354
|
+
status.textContent = `Backtest ${runId} running...`;
|
|
1355
|
+
|
|
1356
|
+
const poll = setInterval(async () => {
|
|
1357
|
+
const r = await fetch(`/api/backtest/status/${runId}`);
|
|
1358
|
+
const d = await r.json();
|
|
1359
|
+
if (d.status === 'completed') {
|
|
1360
|
+
clearInterval(poll);
|
|
1361
|
+
status.className = 'status-completed';
|
|
1362
|
+
status.textContent = `Backtest ${runId} completed!`;
|
|
1363
|
+
btn.disabled = false;
|
|
1364
|
+
} else if (d.status.startsWith('error')) {
|
|
1365
|
+
clearInterval(poll);
|
|
1366
|
+
status.className = 'status-error';
|
|
1367
|
+
status.textContent = `Backtest ${runId}: ${d.status}`;
|
|
1368
|
+
btn.disabled = false;
|
|
1369
|
+
}
|
|
1370
|
+
}, 1000);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1374
|
+
loadStrategies();
|
|
1375
|
+
loadBarPeriods();
|
|
1376
|
+
loadPresets();
|
|
1377
|
+
});
|
|
1378
|
+
"""
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
@app.get("/backtest", response_class=HTMLResponse)
|
|
1382
|
+
async def backtest_form():
|
|
1383
|
+
sidebar = SIDEBAR_HTML.format(
|
|
1384
|
+
runs_active="", backtest_active="active", secmaster_active=""
|
|
1385
|
+
)
|
|
1386
|
+
return f"""
|
|
1387
|
+
<!DOCTYPE html>
|
|
1388
|
+
<html>
|
|
1389
|
+
<head>
|
|
1390
|
+
<title>Backtest - OneSecondTrader</title>
|
|
1391
|
+
<style>{BASE_STYLE}{BACKTEST_FORM_STYLE}</style>
|
|
1392
|
+
</head>
|
|
1393
|
+
<body>
|
|
1394
|
+
{sidebar}
|
|
1395
|
+
<main class="main-content">
|
|
1396
|
+
<div class="container">
|
|
1397
|
+
<div class="card">
|
|
1398
|
+
<h2>Run Backtest</h2>
|
|
1399
|
+
<div class="form-group">
|
|
1400
|
+
<label>Strategy</label>
|
|
1401
|
+
<select id="strategy" onchange="loadStrategyParams()"></select>
|
|
1402
|
+
<div class="section-header">Parameters</div>
|
|
1403
|
+
<div id="strategy-params" class="params-container"></div>
|
|
1404
|
+
</div>
|
|
1405
|
+
<div class="form-group">
|
|
1406
|
+
<label>Bar Period</label>
|
|
1407
|
+
<select id="bar-period" onchange="onBarPeriodChange()"></select>
|
|
1408
|
+
</div>
|
|
1409
|
+
<div class="form-group">
|
|
1410
|
+
<label>Symbols</label>
|
|
1411
|
+
<div class="symbol-section">
|
|
1412
|
+
<div class="preset-row">
|
|
1413
|
+
<select id="preset-select" onchange="loadPreset()"></select>
|
|
1414
|
+
<input type="text" id="preset-name" placeholder="New preset name...">
|
|
1415
|
+
<button class="btn btn-sm btn-secondary" onclick="savePreset()">Save</button>
|
|
1416
|
+
<button class="btn btn-sm btn-danger" onclick="deletePreset()">Delete</button>
|
|
1417
|
+
</div>
|
|
1418
|
+
<div class="search-row">
|
|
1419
|
+
<input type="text" id="symbol-search" placeholder="Search symbols..." oninput="searchSymbols()">
|
|
1420
|
+
</div>
|
|
1421
|
+
<div id="search-results" class="search-results"></div>
|
|
1422
|
+
<div id="selected-label" class="selected-label">Selected (0):</div>
|
|
1423
|
+
<div id="selected-symbols" class="selected-symbols"></div>
|
|
1424
|
+
</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
<div class="date-row">
|
|
1427
|
+
<div class="form-group">
|
|
1428
|
+
<label>Start Date</label>
|
|
1429
|
+
<input type="date" id="start-date" onchange="clampDate('start-date')">
|
|
1430
|
+
</div>
|
|
1431
|
+
<div class="form-group">
|
|
1432
|
+
<label>End Date</label>
|
|
1433
|
+
<input type="date" id="end-date" onchange="clampDate('end-date')">
|
|
1434
|
+
</div>
|
|
1435
|
+
</div>
|
|
1436
|
+
<div class="form-group" style="margin-top: 16px;">
|
|
1437
|
+
<button id="run-btn" class="btn" onclick="runBacktest()">Run Backtest</button>
|
|
1438
|
+
</div>
|
|
1439
|
+
<div id="status"></div>
|
|
1440
|
+
</div>
|
|
1441
|
+
</div>
|
|
1442
|
+
</main>
|
|
1443
|
+
<script>{BACKTEST_FORM_SCRIPT}</script>
|
|
1444
|
+
</body>
|
|
1445
|
+
</html>
|
|
1446
|
+
"""
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _get_secmaster_summary() -> dict:
|
|
1450
|
+
db_path = _get_secmaster_path()
|
|
1451
|
+
if not os.path.exists(db_path):
|
|
1452
|
+
return {"exists": False}
|
|
1453
|
+
|
|
1454
|
+
conn = sqlite3.connect(db_path)
|
|
1455
|
+
cursor = conn.cursor()
|
|
1456
|
+
|
|
1457
|
+
cursor.execute("SELECT key, value FROM meta")
|
|
1458
|
+
meta = {row[0]: row[1] for row in cursor.fetchall()}
|
|
1459
|
+
conn.close()
|
|
1460
|
+
|
|
1461
|
+
if not meta:
|
|
1462
|
+
return {"exists": True, "db_path": db_path, "needs_reindex": True}
|
|
1463
|
+
|
|
1464
|
+
rtypes_str = meta.get("ohlcv_schemas", "")
|
|
1465
|
+
rtypes = [RTYPE_TO_BAR_PERIOD.get(int(r), r) for r in rtypes_str.split(",") if r]
|
|
1466
|
+
|
|
1467
|
+
db_size_bytes = os.path.getsize(db_path)
|
|
1468
|
+
|
|
1469
|
+
return {
|
|
1470
|
+
"exists": True,
|
|
1471
|
+
"db_path": db_path,
|
|
1472
|
+
"db_size_mb": round(db_size_bytes / (1024 * 1024), 1),
|
|
1473
|
+
"symbol_count": int(meta.get("symbol_count", 0)),
|
|
1474
|
+
"ohlcv_record_count": int(meta.get("ohlcv_record_count", 0)),
|
|
1475
|
+
"min_ts": int(meta.get("ohlcv_min_ts", 0)),
|
|
1476
|
+
"max_ts": int(meta.get("ohlcv_max_ts", 0)),
|
|
1477
|
+
"schemas": rtypes,
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _search_symbol(query: str) -> list[dict]:
|
|
1482
|
+
db_path = _get_secmaster_path()
|
|
1483
|
+
if not os.path.exists(db_path):
|
|
1484
|
+
return []
|
|
1485
|
+
|
|
1486
|
+
conn = sqlite3.connect(db_path)
|
|
1487
|
+
cursor = conn.cursor()
|
|
1488
|
+
|
|
1489
|
+
cursor.execute(
|
|
1490
|
+
"""
|
|
1491
|
+
SELECT symbol, rtype, min_ts, max_ts, record_count
|
|
1492
|
+
FROM symbol_coverage
|
|
1493
|
+
WHERE symbol LIKE ?
|
|
1494
|
+
ORDER BY symbol
|
|
1495
|
+
LIMIT 100
|
|
1496
|
+
""",
|
|
1497
|
+
(f"%{query}%",),
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
results = []
|
|
1501
|
+
for row in cursor.fetchall():
|
|
1502
|
+
results.append(
|
|
1503
|
+
{
|
|
1504
|
+
"symbol": row[0],
|
|
1505
|
+
"min_ts": row[2],
|
|
1506
|
+
"max_ts": row[3],
|
|
1507
|
+
"record_count": row[4],
|
|
1508
|
+
"schema": (
|
|
1509
|
+
RTYPE_TO_BAR_PERIOD.get(row[1], str(row[1])) if row[1] else None
|
|
1510
|
+
),
|
|
1511
|
+
}
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
conn.close()
|
|
1515
|
+
return results
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
@app.get("/api/secmaster/summary")
|
|
1519
|
+
async def get_secmaster_summary():
|
|
1520
|
+
return _get_secmaster_summary()
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
@app.get("/api/secmaster/search")
|
|
1524
|
+
async def search_secmaster(q: str = ""):
|
|
1525
|
+
if len(q) < 1:
|
|
1526
|
+
return {"results": []}
|
|
1527
|
+
return {"results": _search_symbol(q)}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
SECMASTER_STYLE = """
|
|
1531
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
1532
|
+
.stat-card { background: #0d1117; border-radius: 8px; padding: 20px; }
|
|
1533
|
+
.stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
1534
|
+
.stat-card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
|
|
1535
|
+
.stat-card .sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
|
|
1536
|
+
.asset-table { width: 100%; border-collapse: collapse; }
|
|
1537
|
+
.asset-table th, .asset-table td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
|
1538
|
+
.asset-table th { font-size: 12px; color: #8b949e; text-transform: uppercase; font-weight: 500; }
|
|
1539
|
+
.asset-table tr:hover { background: #21262d; }
|
|
1540
|
+
.search-box { width: 100%; padding: 12px 16px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 14px; margin-bottom: 16px; }
|
|
1541
|
+
.search-box:focus { outline: none; border-color: #58a6ff; }
|
|
1542
|
+
.search-results { max-height: 400px; overflow-y: auto; }
|
|
1543
|
+
.no-db { text-align: center; padding: 48px; color: #8b949e; }
|
|
1544
|
+
.no-db code { background: #21262d; padding: 2px 6px; border-radius: 4px; }
|
|
1545
|
+
.loading { display: flex; align-items: center; gap: 12px; color: #8b949e; padding: 24px; }
|
|
1546
|
+
.spinner { width: 20px; height: 20px; border: 2px solid #30363d; border-top-color: #58a6ff; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
1547
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1548
|
+
"""
|
|
1549
|
+
|
|
1550
|
+
SECMASTER_SCRIPT = """
|
|
1551
|
+
function showLoading(elementId, message) {
|
|
1552
|
+
document.getElementById(elementId).innerHTML = `<div class="loading"><div class="spinner"></div><span>${message}</span></div>`;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
async function loadSummary() {
|
|
1556
|
+
showLoading('content', 'Loading database summary...');
|
|
1557
|
+
const res = await fetch('/api/secmaster/summary');
|
|
1558
|
+
const data = await res.json();
|
|
1559
|
+
|
|
1560
|
+
if (!data.exists) {
|
|
1561
|
+
document.getElementById('content').innerHTML = `
|
|
1562
|
+
<div class="no-db">
|
|
1563
|
+
<p>No securities master database found.</p>
|
|
1564
|
+
<p style="margin-top: 8px;">Set <code>SECMASTER_DB_PATH</code> environment variable or place <code>secmaster.db</code> in the working directory.</p>
|
|
1565
|
+
</div>
|
|
1566
|
+
`;
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (data.needs_reindex) {
|
|
1571
|
+
document.getElementById('content').innerHTML = `
|
|
1572
|
+
<div class="no-db">
|
|
1573
|
+
<p>Database metadata not found.</p>
|
|
1574
|
+
<p style="margin-top: 8px;">Run <code>from onesecondtrader.secmaster.utils import update_meta; update_meta(path)</code> to rebuild stats.</p>
|
|
1575
|
+
</div>
|
|
1576
|
+
`;
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const minDate = new Date(data.min_ts / 1000000).toISOString().split('T')[0];
|
|
1581
|
+
const maxDate = new Date(data.max_ts / 1000000).toISOString().split('T')[0];
|
|
1582
|
+
|
|
1583
|
+
document.getElementById('content').innerHTML = `
|
|
1584
|
+
<div class="stats-grid">
|
|
1585
|
+
<div class="stat-card">
|
|
1586
|
+
<div class="label">Symbols</div>
|
|
1587
|
+
<div class="value">${data.symbol_count.toLocaleString()}</div>
|
|
1588
|
+
</div>
|
|
1589
|
+
<div class="stat-card">
|
|
1590
|
+
<div class="label">OHLCV Records</div>
|
|
1591
|
+
<div class="value">${data.ohlcv_record_count.toLocaleString()}</div>
|
|
1592
|
+
</div>
|
|
1593
|
+
<div class="stat-card">
|
|
1594
|
+
<div class="label">Date Range</div>
|
|
1595
|
+
<div class="value" style="font-size: 18px;">${minDate}</div>
|
|
1596
|
+
<div class="sub">to ${maxDate}</div>
|
|
1597
|
+
</div>
|
|
1598
|
+
<div class="stat-card">
|
|
1599
|
+
<div class="label">Schemas</div>
|
|
1600
|
+
<div class="value" style="font-size: 18px;">${data.schemas.join(', ')}</div>
|
|
1601
|
+
</div>
|
|
1602
|
+
<div class="stat-card">
|
|
1603
|
+
<div class="label">Database Size</div>
|
|
1604
|
+
<div class="value">${data.db_size_mb} MB</div>
|
|
1605
|
+
<div class="sub">${data.db_path}</div>
|
|
1606
|
+
</div>
|
|
1607
|
+
</div>
|
|
1608
|
+
<div class="card">
|
|
1609
|
+
<h2>Symbol Search</h2>
|
|
1610
|
+
<input type="text" class="search-box" id="search-input" placeholder="Search symbols..." oninput="searchSymbols()">
|
|
1611
|
+
<div id="search-results" class="search-results"></div>
|
|
1612
|
+
</div>
|
|
1613
|
+
`;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
let searchTimeout;
|
|
1617
|
+
async function searchSymbols() {
|
|
1618
|
+
const q = document.getElementById('search-input').value;
|
|
1619
|
+
clearTimeout(searchTimeout);
|
|
1620
|
+
if (q.length < 1) {
|
|
1621
|
+
document.getElementById('search-results').innerHTML = '';
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
showLoading('search-results', 'Searching...');
|
|
1625
|
+
searchTimeout = setTimeout(async () => {
|
|
1626
|
+
const res = await fetch('/api/secmaster/search?q=' + encodeURIComponent(q));
|
|
1627
|
+
const data = await res.json();
|
|
1628
|
+
if (data.results.length === 0) {
|
|
1629
|
+
document.getElementById('search-results').innerHTML = '<p style="color: #8b949e;">No results found.</p>';
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
let rows = data.results.map(r => {
|
|
1633
|
+
const minDate = r.min_ts ? new Date(r.min_ts / 1000000).toISOString().split('T')[0] : '-';
|
|
1634
|
+
const maxDate = r.max_ts ? new Date(r.max_ts / 1000000).toISOString().split('T')[0] : '-';
|
|
1635
|
+
return `<tr>
|
|
1636
|
+
<td>${r.symbol}</td>
|
|
1637
|
+
<td>${r.schema || '-'}</td>
|
|
1638
|
+
<td>${minDate} → ${maxDate}</td>
|
|
1639
|
+
<td>${r.record_count ? r.record_count.toLocaleString() : '-'}</td>
|
|
1640
|
+
</tr>`;
|
|
1641
|
+
}).join('');
|
|
1642
|
+
document.getElementById('search-results').innerHTML = `
|
|
1643
|
+
<table class="asset-table">
|
|
1644
|
+
<thead><tr><th>Symbol</th><th>Schema</th><th>Date Range</th><th>Records</th></tr></thead>
|
|
1645
|
+
<tbody>${rows}</tbody>
|
|
1646
|
+
</table>
|
|
1647
|
+
`;
|
|
1648
|
+
}, 300);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
document.addEventListener('DOMContentLoaded', loadSummary);
|
|
1652
|
+
"""
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
@app.get("/secmaster", response_class=HTMLResponse)
|
|
1656
|
+
async def secmaster_page():
|
|
1657
|
+
sidebar = SIDEBAR_HTML.format(
|
|
1658
|
+
runs_active="", backtest_active="", secmaster_active="active"
|
|
1659
|
+
)
|
|
1660
|
+
return f"""
|
|
1661
|
+
<!DOCTYPE html>
|
|
1662
|
+
<html>
|
|
1663
|
+
<head>
|
|
1664
|
+
<title>Securities Master - OneSecondTrader</title>
|
|
1665
|
+
<style>{BASE_STYLE}{SECMASTER_STYLE}</style>
|
|
1666
|
+
</head>
|
|
1667
|
+
<body>
|
|
1668
|
+
{sidebar}
|
|
1669
|
+
<main class="main-content">
|
|
1670
|
+
<div class="container" id="content">
|
|
1671
|
+
<p style="color: #8b949e;">Loading...</p>
|
|
1672
|
+
</div>
|
|
1673
|
+
</main>
|
|
1674
|
+
<script>{SECMASTER_SCRIPT}</script>
|
|
1675
|
+
</body>
|
|
1676
|
+
</html>
|
|
1677
|
+
"""
|