Skip to content

Commit

Permalink
refactor strategy configuration & runtime-state
Browse files Browse the repository at this point in the history
- use zod schemas for validation
- simplify runtime state loading
- merge `url` into StrategyRuntimeState (simplifies data loading)
- fixes #655
  • Loading branch information
kenkunz committed Jan 10, 2024
1 parent 6e58683 commit 0cf82e3
Show file tree
Hide file tree
Showing 28 changed files with 149 additions and 202 deletions.
6 changes: 0 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
"@wagmi/core": "^1.4.5",
"@walletconnect/modal": "^2.6.2",
"@web3modal/ethereum": "^2.7.1",
"assert-ts": "^0.3.4",
"bignumber.js": "^9.1.2",
"cheerio": "^1.0.0-rc.12",
"cookie": "^0.5.0",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/trade-executor/components/KeyMetric.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Display one key metric in a strategy tile.
```
-->
<script lang="ts">
import type { KeyMetric } from 'trade-executor/strategy/runtime-state';
import type { KeyMetric } from 'trade-executor/statistics/key-metric';
import { Icon, Tooltip } from '$lib/components';
import KeyMetricDescription from './KeyMetricDescription.svelte';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import type { KeyMetric } from 'trade-executor/strategy/runtime-state';
import type { KeyMetric } from 'trade-executor/statistics/key-metric';
import { Timestamp } from '$lib/components';
export let title: string;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/trade-executor/state/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { error } from '@sveltejs/kit';
import { publicApiError } from '$lib/helpers/public-api';
import { getConfiguredStrategyById } from '../strategy/configuration';
import { configuredStrategies } from '../strategy/configuration';
import { stateSchema } from './state';

export async function getStrategyState(fetch: Fetch, strategyId: string, raw = false) {
const strategy = getConfiguredStrategyById(strategyId);
const strategy = configuredStrategies.get(strategyId);
if (!strategy) throw error(404, 'Not found');

const url = `${strategy.url}/state`;
Expand Down
1 change: 1 addition & 0 deletions src/lib/trade-executor/statistics/key-metric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const keyMetricKind = z.enum([
'max_pullback_of_total_capital',
'max_loss_risk_at_opening_of_position'
]);
export type KeyMetricKind = z.infer<typeof keyMetricKind>;

export const keyMetricSource = z.enum(['backtesting', 'live_trading', 'missing']);

Expand Down
51 changes: 20 additions & 31 deletions src/lib/trade-executor/strategy/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,27 @@
import { strategyConfig } from '$lib/config';
import { z } from 'zod';

/**
* TypeScript helper for having frontend side configuration for strategies.
*/
export interface StrategyConfiguration {
/** Strategy id - used internally in the state files, etc. */
id: string;

/** Name displayed until we have loaded data from the server-side */
name: string;
export const strategyConfigurationSchema = z.object({
id: z.string(),
name: z.string(),
url: z.string().url()
});
export type StrategyConfiguration = z.infer<typeof strategyConfigurationSchema>;

/** Webhook server URL */
url: string;
}
export type ConfiguredStrategies = Map<string, StrategyConfiguration>;

/**
* Get list of configured strategies.
*
* Typedefs JSON load from the config.
* export all configured strategies as a Map for easy iteration and lookup
*/
export function getConfiguredStrategies(): StrategyConfiguration[] {
if (!!strategyConfig) {
return strategyConfig;
}

return [];
}

export function getConfiguredStrategyById(id: string): StrategyConfiguration | null {
const strats = getConfiguredStrategies();
for (let strat of strats) {
if (strat.id == id) {
return strat;
export const configuredStrategies: ConfiguredStrategies = strategyConfig.reduce(
(acc: ConfiguredStrategies, strat: any) => {
try {
acc.set(strat.id, strategyConfigurationSchema.parse(strat));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn('Failed to parse strategy config', strat, message);
}
}
return null;
}
return acc;
},
new Map()
);
114 changes: 35 additions & 79 deletions src/lib/trade-executor/strategy/runtime-state.ts
Original file line number Diff line number Diff line change
@@ -1,95 +1,51 @@
/**
* Strategy runtime state fetching.
*/

import { getConfiguredStrategies } from './configuration';
import type { StrategyConfiguration } from './configuration';
// https://github.com/fram-x/assert-ts/issues/23
import { assert } from 'assert-ts';
import loadError from '../assets/load-error.jpg';
import { type StrategyConfiguration, configuredStrategies } from './configuration';
import { type StrategySummary, strategySummarySchema } from './summary';
import loadError from '../assets/load-error.jpg';

// use 5 second timeout when fetching strategy metadata
const clientTimeout = 5000;
const CLIENT_TIMEOUT = 5000;

export type ConnectedRuntimeState = StrategySummary & {
connected: true;
id: string;
};
export type ConnectedStrategyRuntimeState = StrategyConfiguration &
StrategySummary & {
connected: true;
};

export type DisconnectedRuntimeState = {
export type DisconnectedStrategyRuntimeState = StrategyConfiguration & {
connected: false;
id: string;
name: string;
icon_url: string;
error: string;
};

export type StrategyRuntimeState = ConnectedRuntimeState | DisconnectedRuntimeState;

export async function getStrategiesWithRuntimeState(
strats: StrategyConfiguration[],
fetch: Fetch
): Promise<StrategyRuntimeState[]> {
// Load runtime state for all strategies parallel
return await Promise.all(
strats.map(async ({ id, name, url }) => {
assert(url, `StrategyConfig URL missing: ${id}`);

const endpoint = `${url}/metadata`;
let resp: Partial<Response>;
let error: string;

try {
resp = await fetch(endpoint, { signal: AbortSignal.timeout(clientTimeout) });
} catch (e) {
resp = { ok: false, statusText: e.message };
}

if (resp.ok) {
try {
const payload = await resp.json!();

const safe = strategySummarySchema.safeParse(payload);
if (!safe.success) {
console.error(safe.error.issues);
}

const summary = strategySummarySchema.parse(payload);
return { connected: true, id, ...summary };
} catch (e) {
error = (e as Error).message ?? `Error parsing response from ${endpoint}`;
}
} else {
error = resp.statusText ?? `Error fetching ${endpoint}`;
}

return {
connected: false,
id,
name,
icon_url: loadError,
error
};
})
);
export type StrategyRuntimeState = ConnectedStrategyRuntimeState | DisconnectedStrategyRuntimeState;

export async function getStrategyRuntimeState(fetch: Fetch, id: string): Promise<StrategyRuntimeState | undefined> {
const strategy = configuredStrategies.get(id);
if (!strategy) return;

try {
const resp = await fetch(`${strategy.url}/metadata`, { signal: AbortSignal.timeout(CLIENT_TIMEOUT) });
if (!resp.ok) throw new Error(resp.statusText);
const summary = strategySummarySchema.parse(await resp.json());
return { connected: true, ...strategy, ...summary };
} catch (e) {
return {
connected: false,
...strategy,
icon_url: loadError,
error: e instanceof Error ? e.message : String(e)
};
}
}

/**
* Get list of configured strategies and pings server for the latest runtime state.
*
* Typedefs JSON load from the config.
*/
export async function getConfiguredStrategiesWithRuntimeState(fetch: Fetch) {
const strats = getConfiguredStrategies();
return getStrategiesWithRuntimeState(strats, fetch);
}

/**
* Get runtime state for a single strategy
*
*/
export async function getStrategyRuntimeState(strategyConfig: StrategyConfiguration, fetch: Fetch) {
const arr = await getStrategiesWithRuntimeState([strategyConfig], fetch);
return arr[0];
export async function getStrategiesWithRuntimeState(fetch: Fetch) {
// prettier-ignore
return Promise.all(
Array.from(
configuredStrategies,
async ([id]) => getStrategyRuntimeState(fetch, id) as Promise<StrategyRuntimeState>
)
);
}
4 changes: 2 additions & 2 deletions src/lib/wallet/MyDeposits.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { ComponentEvents } from 'svelte';
import type { ApiChain } from '$lib/helpers/chain';
import type { StrategyRuntimeState } from 'trade-executor/strategy/runtime-state';
import type { ConnectedStrategyRuntimeState } from 'trade-executor/strategy/runtime-state';
import fsm from 'svelte-fsm';
import { goto } from '$app/navigation';
import { switchNetwork } from '@wagmi/core';
Expand All @@ -10,7 +10,7 @@
import { Button, HashAddress, Icon } from '$lib/components';
import { formatDollar } from '$lib/helpers/formatters';
export let strategy: StrategyRuntimeState;
export let strategy: ConnectedStrategyRuntimeState;
export let chain: ApiChain;
let contentWrapper: HTMLElement;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/wallet/WalletWidget.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<script lang="ts">
import type { ApiChain } from '$lib/helpers/chain';
import type { StrategyRuntimeState } from 'trade-executor/strategy/runtime-state';
import type { ConnectedStrategyRuntimeState } from 'trade-executor/strategy/runtime-state';
import { goto } from '$app/navigation';
import { wizard } from 'wizard/store';
import { wallet } from '$lib/wallet';
import { Button, HashAddress, Icon } from '$lib/components';
export let chain: ApiChain;
export let strategy: StrategyRuntimeState;
export let strategy: ConnectedStrategyRuntimeState;
$: contracts = strategy.on_chain_data.smart_contracts;
Expand Down
4 changes: 2 additions & 2 deletions src/routes/strategies/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* Strategies are cached in-process using SWR cache with 5 minute TTL
*/
import swrCache from '$lib/swrCache.js';
import { getConfiguredStrategiesWithRuntimeState } from 'trade-executor/strategy/runtime-state';
import { getStrategiesWithRuntimeState } from 'trade-executor/strategy/runtime-state';

// Create a SWR cache for strategies with 1 minute TTL
const cacheTimeSeconds = 60;
const getCachedStrategies = swrCache(getConfiguredStrategiesWithRuntimeState, cacheTimeSeconds);
const getCachedStrategies = swrCache(getStrategiesWithRuntimeState, cacheTimeSeconds);

export async function load({ fetch, setHeaders }) {
const strategies = await getCachedStrategies(fetch);
Expand Down
4 changes: 2 additions & 2 deletions src/routes/strategies/StrategyDataSummary.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
export let strategy: StrategyRuntimeState;
const metrics = strategy.summary_statistics?.key_metrics ?? {};
const strategyId = strategy.id;
const hasEnzymeVault = strategy.on_chain_data?.asset_management_mode === 'enzyme';
const metrics = strategy.connected ? strategy.summary_statistics.key_metrics : {};
const hasEnzymeVault = strategy.connected && strategy.on_chain_data.asset_management_mode === 'enzyme';
</script>

<dl class="strategy-data-summary ds-3">
Expand Down
15 changes: 10 additions & 5 deletions src/routes/strategies/StrategyTile.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,27 @@
import type { EventHandler } from 'svelte/elements';
import type { ApiChain } from '$lib/helpers/chain.js';
import type { StrategyRuntimeState } from 'trade-executor/strategy/runtime-state';
import { type RawTick, type Quote, rawTicksToQuotes } from '$lib/chart';
import { goto } from '$app/navigation';
import { Alert, Button, EntitySymbol, Tooltip } from '$lib/components';
import ChartThumbnail from './ChartThumbnail.svelte';
import StrategyDataSummary from './StrategyDataSummary.svelte';
import { rawTicksToQuotes } from '$lib/chart';
import { getTradeExecutorErrorHtml } from 'trade-executor/strategy/error';
export let strategy: StrategyRuntimeState;
export let chain: ApiChain;
const href = `/strategies/${strategy.id}`;
const summaryStatistics = strategy.summary_statistics ?? {};
const chartData = rawTicksToQuotes(summaryStatistics.performance_chart_90_days ?? []);
const errorHtml = getTradeExecutorErrorHtml(strategy);
const keyMetrics = summaryStatistics.key_metrics ?? {};
const isBacktested = Object.values(keyMetrics).some(({ source }) => source === 'backtesting');
let chartData: Quote[] = [];
let isBacktested = false;
if (strategy.connected) {
const stats = strategy.summary_statistics;
chartData = rawTicksToQuotes(stats.performance_chart_90_days as RawTick[]);

Check failure on line 23 in src/routes/strategies/StrategyTile.svelte

View workflow job for this annotation

GitHub Actions / test

TypeError: Cannot read properties of undefined (reading 'performance_chart_90_days')

at instance frontend/src/routes/strategies/StrategyTile.svelte:23:38 at Module.init frontend/node_modules/svelte/src/runtime/internal/Component.js:135:5 at new StrategyTile frontend/src/routes/strategies/StrategyTile.svelte:1189:25 at Module.createProxiedComponent frontend/node_modules/svelte-hmr/runtime/svelte-hooks.js:338:9 at new ProxyComponent frontend/node_modules/svelte-hmr/runtime/proxy.js:243:29 at new Proxy<StrategyTile> frontend/node_modules/svelte-hmr/runtime/proxy.js:351:11 at Module.render frontend/node_modules/@testing-library/svelte/src/pure.js:57:19 at frontend/src/routes/strategies/StrategyTile.test.ts:33:25
isBacktested = Object.values(stats.key_metrics).some(({ source }) => source === 'backtesting');
}
const handleClick: EventHandler = ({ target }) => {
// skip explicit goto if user clicked an anchor tag
Expand Down
16 changes: 8 additions & 8 deletions src/routes/strategies/[strategy]/(nav)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
import { getTradeExecutorErrorHtml } from 'trade-executor/strategy/error';
export let data;
$: ({ chain, summary, state } = data);
$: ({ chain, strategy, state } = data);
$: backtestAvailable = summary.backtest_available;
$: backtestAvailable = strategy.backtest_available;
// Get the error message HTML
$: errorHtml = getTradeExecutorErrorHtml(summary);
$: errorHtml = getTradeExecutorErrorHtml(strategy);
</script>

<main class="strategy-layout ds-container">
<PageHeading title={summary.name} description={summary.short_description}>
<img slot="icon" src={summary.icon_url} alt={summary.name} />
<PageHeading title={strategy.name} description={strategy.short_description}>
<img slot="icon" src={strategy.icon_url} alt={strategy.name} />
<div class="wallet-widget" slot="cta">
<WalletWidget strategy={summary} {chain} />
<WalletWidget {strategy} {chain} />
</div>
</PageHeading>

Expand All @@ -31,9 +31,9 @@

<div class="subpage">
<StrategyNav
strategyId={summary.id}
strategyId={strategy.id}
portfolio={state.portfolio}
onChainData={summary.on_chain_data}
onChainData={strategy.on_chain_data}
currentPath={$page.url.pathname}
{backtestAvailable}
/>
Expand Down
Loading

0 comments on commit 0cf82e3

Please sign in to comment.