Skip to content

Commit 857c450

Browse files
authored
Merge pull request #983 from tradingstrategy-ai/980-strategy-charts
Add strategy charts (analysis) page
2 parents eee7a2d + cda3376 commit 857c450

12 files changed

Lines changed: 549 additions & 6 deletions

File tree

src/lib/components/SummaryBox.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Possible `ctaPosition` values include: top, bottom, toggle (default).
7272
}
7373
}
7474
75-
:global(header) {
75+
> :global(header) {
7676
display: grid;
7777
grid-template-columns: 1fr auto;
7878
gap: var(--space-sm) var(--space-lg);

src/lib/components/css/color.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
:root {
22
/* Supported color schemes */
3-
color-scheme: light dark;
3+
color-scheme: dark;
44

55
/* Global color hues */
66
--hue-1: 36;
@@ -72,6 +72,8 @@
7272
* theme via postcss plugin (see postcss.config.js)
7373
*/
7474
@media (prefers-color-scheme: light) {
75+
color-scheme: light;
76+
7577
--c-body: hsl(var(--hue-1) 100% 96%);
7678

7779
--c-text: hsl(var(--hue-1) 9% 7%);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* zod schemas for charts
3+
*
4+
* Based on Python classes found in:
5+
* https://github.com/tradingstrategy-ai/trade-executor/blob/master/tradeexecutor/strategy/chart/definition.py
6+
*
7+
*/
8+
import { z } from 'zod';
9+
import { tradingPairIdentifierSchema } from './identifier';
10+
import { createTradingPairInfo } from 'trade-executor/models/trading-pair-info';
11+
12+
export const chartKind = z.enum([
13+
'indicator_single_pair',
14+
'indicator_multi_pair',
15+
'indicator_universe',
16+
'universe_state',
17+
'state_single_pair',
18+
'state_single_vault_pair'
19+
]);
20+
21+
export const chartRegistrationSchema = z.object({
22+
id: z.string(),
23+
name: z.string().nullable(),
24+
kind: chartKind,
25+
description: z.string()
26+
});
27+
export type ChartRegistration = z.infer<typeof chartRegistrationSchema>;
28+
29+
// NOTE: using name ChartRegistrations instead of ChartRegistry because this
30+
// is exposed via the trade-executor API as an array rather than a record.
31+
export const chartRegistrationsSchema = z.array(chartRegistrationSchema);
32+
export type ChartRegistrations = z.infer<typeof chartRegistrationsSchema>;
33+
34+
export const tradingPairsSchema = z.array(tradingPairIdentifierSchema.transform(createTradingPairInfo));
35+
export type TradingPairs = z.infer<typeof tradingPairsSchema>;
36+
37+
export const chartPairsSchema = z.object({
38+
default_pairs: tradingPairsSchema,
39+
all_pairs: tradingPairsSchema
40+
});
41+
export type ChartPairs = z.infer<typeof chartPairsSchema>;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script lang="ts">
2+
import { page } from '$app/state';
3+
import { discordUrl } from '$lib/config';
4+
import Alert from '$lib/components/Alert.svelte';
5+
import Button from '$lib/components/Button.svelte';
6+
import IconDiscord from '~icons/local/discord';
7+
8+
let title = $derived.by(() => {
9+
let message = page.error?.message;
10+
message ??= page.status < 500 ? 'Client request error' : 'Internal Error';
11+
return `${page.status} ${message}`;
12+
});
13+
</script>
14+
15+
<svelte:head>
16+
<title>Error: {page.status}</title>
17+
</svelte:head>
18+
19+
<section class="tech-details-error">
20+
<Alert size="lg" status="error" {title}>
21+
<pre>{(page.error?.stack ?? ['Unknown error']).join('\n')}</pre>
22+
</Alert>
23+
<div class="buttons">
24+
<Button secondary label="Get help on Discord" href={discordUrl} target="_blank" rel="noreferrer">
25+
<IconDiscord slot="icon" />
26+
</Button>
27+
</div>
28+
</section>
29+
30+
<style>
31+
.tech-details-error {
32+
pre {
33+
margin-top: 1rem !important;
34+
font: var(--f-code-md-medium);
35+
white-space: pre-wrap;
36+
}
37+
38+
.buttons {
39+
display: grid;
40+
place-items: center;
41+
height: 100%;
42+
}
43+
}
44+
</style>

src/routes/strategies/[strategy]/tech-details/+layout.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script lang="ts">
22
import { page } from '$app/stores';
3-
import { Menu, MenuItem, SummaryBox } from '$lib/components';
3+
import Menu from '$lib/components/Menu.svelte';
4+
import MenuItem from '$lib/components/MenuItem.svelte';
5+
import SummaryBox from '$lib/components/SummaryBox.svelte';
46
57
$: currentTab = $page.url.pathname.split('/').at(-1);
68
</script>
@@ -10,6 +12,7 @@
1012
<Menu horizontal>
1113
<MenuItem label="Status" targetUrl="status" active={currentTab === 'status'} />
1214
<MenuItem label="Logs" targetUrl="logs" active={currentTab === 'logs'} />
15+
<MenuItem label="Analysis" targetUrl="analysis" active={currentTab === 'analysis'} />
1316
<MenuItem label="Decision making" targetUrl="decision-making" active={currentTab === 'decision-making'} />
1417
</Menu>
1518
</header>

src/routes/strategies/[strategy]/tech-details/+layout.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ export async function load() {
33
breadcrumbs: {
44
status: 'Instance status',
55
logs: 'Logs',
6-
'decision-making': 'Decision making'
6+
'decision-making': 'Decision making',
7+
analysis: 'Analysis'
78
}
89
};
910
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { z } from 'zod';
2+
import { error } from '@sveltejs/kit';
3+
import { publicApiError } from '$lib/helpers/public-api';
4+
import { chartRegistrationsSchema, chartPairsSchema } from 'trade-executor/schemas/chart.js';
5+
6+
async function fetchChartEndpoint<T extends z.ZodTypeAny>(fetch: Fetch, url: string, schema: T): Promise<z.infer<T>> {
7+
try {
8+
const resp = await fetch(url);
9+
if (!resp.ok) throw await publicApiError(resp);
10+
return schema.parse(await resp.json());
11+
} catch (e) {
12+
const stack = [`Error loading data from URL: ${url}`, e.message];
13+
error(503, { message: 'Service Unavailable', stack });
14+
}
15+
}
16+
17+
export async function load({ fetch, parent }) {
18+
const { admin, strategy } = await parent();
19+
20+
if (!admin) error(401, 'Unauthorized');
21+
22+
const chartRegistrationsPromise = fetchChartEndpoint(
23+
fetch,
24+
`${strategy.url}/chart-registry`,
25+
chartRegistrationsSchema
26+
);
27+
28+
const pairsPromise = fetchChartEndpoint(fetch, `${strategy.url}/chart-registry/pairs`, chartPairsSchema).catch(() => {
29+
// gracefully handle pairs endpoint failure
30+
return {
31+
default_pairs: [],
32+
all_pairs: []
33+
};
34+
});
35+
36+
return {
37+
chartRegistrations: await chartRegistrationsPromise,
38+
tradingPairs: await pairsPromise
39+
};
40+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<script lang="ts">
2+
import { goto } from '$app/navigation';
3+
import Alert from '$lib/components/Alert.svelte';
4+
import Spinner from '$lib/components/Spinner.svelte';
5+
import PairsSelector from './PairsSelector.svelte';
6+
7+
let { data } = $props();
8+
let { strategy, chartRegistrations, selectedChart, tradingPairs, selectedPairIds, contentPromise } = $derived(data);
9+
10+
function updateAnalysis({ chart_id, pair_ids }: { chart_id?: string; pair_ids?: number[] }) {
11+
const params = new URLSearchParams({
12+
chart_id: chart_id ?? selectedChart?.id ?? '',
13+
pair_ids: (pair_ids ?? selectedPairIds).join(',')
14+
});
15+
goto(`?${params}`, { noScroll: true });
16+
}
17+
</script>
18+
19+
<svelte:head>
20+
<title>Analysis | {strategy.name} | Trading Strategy</title>
21+
<meta name="description" content="Analysis charts and tables for {strategy.name} strategy" />
22+
</svelte:head>
23+
24+
<section class="analysis">
25+
<div class="controls">
26+
<select onchange={(e) => updateAnalysis({ chart_id: e.currentTarget.value })}>
27+
<option value="">Select analysis</option>
28+
{#each chartRegistrations as { id, name } (id)}
29+
<option value={id} selected={id === selectedChart?.id}>{name}</option>
30+
{/each}
31+
</select>
32+
<PairsSelector
33+
{selectedPairIds}
34+
{tradingPairs}
35+
disabled={selectedChart?.kind !== 'indicator_multi_pair'}
36+
onchange={(pair_ids) => updateAnalysis({ pair_ids })}
37+
/>
38+
</div>
39+
40+
<div class="content">
41+
{#await contentPromise}
42+
<div class="loading">
43+
<Spinner size="60" />
44+
</div>
45+
{:then content}
46+
{#if !content}
47+
<p>Select analysis from the drop-down above.</p>
48+
{:else if content.type === 'image/png'}
49+
<img src={URL.createObjectURL(content.data)} alt="Analysis content" />
50+
{:else if content.type === 'text/html'}
51+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
52+
{@html content.data}
53+
{/if}
54+
{:catch error}
55+
<Alert size="md" status="error" title="Error loading analysis">
56+
<pre class="error-detail">{error}</pre>
57+
</Alert>
58+
{/await}
59+
</div>
60+
</section>
61+
62+
<style>
63+
.analysis {
64+
position: relative;
65+
display: grid;
66+
gap: 1.5rem;
67+
68+
select {
69+
border-radius: var(--radius-sm);
70+
padding: 0.5rem;
71+
margin-bottom: 1rem;
72+
}
73+
74+
.controls {
75+
display: grid;
76+
grid-template-columns: auto 1fr;
77+
align-items: center;
78+
gap: 1rem;
79+
80+
select {
81+
margin: 0;
82+
}
83+
}
84+
85+
.content {
86+
min-height: 500px;
87+
overflow: auto;
88+
89+
p {
90+
padding: 1rem 0.5rem;
91+
}
92+
93+
:global(table) {
94+
width: 100%;
95+
border-collapse: collapse;
96+
color: inherit;
97+
background: var(--c-box-1);
98+
font: var(--f-mono-sm-regular);
99+
line-height: 1.2;
100+
letter-spacing: var(--f-mono-sm-spacing, normal);
101+
102+
@media (--viewport-xs) {
103+
font-size: 12px;
104+
}
105+
106+
:global(:is(td, th)) {
107+
padding: 0.25em 0.5em;
108+
border-block: 1px solid var(--c-text-ultra-light);
109+
vertical-align: top;
110+
111+
&:first-child {
112+
padding-left: 0.25em;
113+
}
114+
115+
&:last-child {
116+
padding-right: 0.25em;
117+
}
118+
}
119+
120+
:global(tbody :is(td, th)) {
121+
/* Alternating column colors */
122+
&:nth-child(even) {
123+
background-color: var(--c-box-3);
124+
}
125+
126+
&:nth-child(odd) {
127+
background-color: var(--c-box-1);
128+
}
129+
}
130+
131+
:global(thead th) {
132+
background: var(--c-box-3);
133+
font-weight: 900;
134+
border-bottom: 2px solid currentColor;
135+
}
136+
}
137+
}
138+
139+
.loading {
140+
display: grid;
141+
place-content: center;
142+
min-height: inherit;
143+
}
144+
145+
.error-detail {
146+
white-space: pre-wrap;
147+
font: var(--f-code-md-regular);
148+
}
149+
}
150+
</style>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ChartRegistration, TradingPairs } from 'trade-executor/schemas/chart.js';
2+
3+
// Use discriminated union so data type is correctly inferred
4+
type AnalysisContent =
5+
| {
6+
type: 'image/png';
7+
data: Blob;
8+
}
9+
| {
10+
type: 'text/html';
11+
data: string;
12+
};
13+
14+
async function fetchAnalysisContent(
15+
fetch: Fetch,
16+
strategyUrl: string,
17+
chartRegistration: ChartRegistration,
18+
pairIds: number[]
19+
): Promise<AnalysisContent> {
20+
const params = new URLSearchParams({ chart_id: chartRegistration.id });
21+
22+
// add pair_ids param if required by the chart kind
23+
if (chartRegistration.kind === 'indicator_multi_pair') {
24+
params.set('pair_ids', pairIds.join(','));
25+
}
26+
27+
const response = await fetch(`${strategyUrl}/chart-registry/render?${params}`);
28+
const type = response.headers.get('content-type') ?? 'unknown';
29+
30+
if (type.startsWith('image/png')) {
31+
return {
32+
type: 'image/png',
33+
data: await response.blob()
34+
};
35+
}
36+
37+
if (type.startsWith('text/html')) {
38+
return {
39+
type: 'text/html',
40+
data: await response.text()
41+
};
42+
}
43+
44+
throw new Error(await response.text());
45+
}
46+
47+
export async function load({ fetch, parent, url }) {
48+
const { strategy, chartRegistrations, tradingPairs } = await parent();
49+
50+
const chartId = url.searchParams.get('chart_id') ?? undefined;
51+
const selectedChart = chartRegistrations.find(({ id }) => id === chartId);
52+
53+
const pairIds = url.searchParams.get('pair_ids')?.split(',') ?? [];
54+
55+
let selectedPairs = tradingPairs.all_pairs.filter((p) => {
56+
return pairIds.find((id) => Number(id) === p.internal_id);
57+
});
58+
59+
if (!selectedPairs.length) selectedPairs = tradingPairs.default_pairs;
60+
61+
const selectedPairIds = selectedPairs.map((p) => p.internal_id!);
62+
63+
const contentPromise = selectedChart && fetchAnalysisContent(fetch, strategy.url, selectedChart, selectedPairIds);
64+
65+
return { chartRegistrations, selectedChart, selectedPairIds, contentPromise };
66+
}

0 commit comments

Comments
 (0)