data fixes; indicator=>workspace sync

This commit is contained in:
2026-03-31 20:29:12 -04:00
parent 998f69fa1a
commit cd28e18e52
45 changed files with 1324 additions and 1239 deletions

View File

@@ -16,6 +16,7 @@ import {
type SnapshotMessage,
type PatchMessage,
} from '../workspace/index.js';
import { resolutionToSeconds } from '../types/ohlc.js';
/**
* Safe JSON stringifier that handles BigInt values
@@ -486,7 +487,7 @@ export class WebSocketHandler {
}
const history = await ohlcService.fetchOHLC(
payload.symbol,
payload.resolution,
resolutionToSeconds(payload.resolution),
payload.from_time,
payload.to_time,
payload.countback

View File

@@ -420,7 +420,7 @@ export class DuckDBClient {
WHERE ticker = ?
AND period_seconds = ?
AND timestamp >= ?
AND timestamp <= ?
AND timestamp < ?
ORDER BY timestamp ASC
`;
@@ -441,10 +441,11 @@ export class DuckDBClient {
count: rows.length
}, 'Loaded OHLC data from Iceberg');
// Convert timestamp strings to numbers (microseconds as Number is fine for display)
// Keep timestamp as bigint to preserve full microsecond precision.
// Convert to seconds (divide first) only when producing TradingView bars.
return rows.map((row: any) => ({
...row,
timestamp: Number(row.timestamp)
timestamp: BigInt(row.timestamp)
}));
} catch (error: any) {
this.logger.error({
@@ -484,7 +485,7 @@ export class DuckDBClient {
WHERE ticker = ?
AND period_seconds = ?
AND timestamp >= ?
AND timestamp <= ?
AND timestamp < ?
`;
const params = [
@@ -525,6 +526,7 @@ export class DuckDBClient {
// For now, simple check: if we have any data, assume complete
// TODO: Implement proper gap detection by checking for missing periods
const periodMicros = BigInt(period_seconds) * 1000000n;
// end_time is exclusive, so expected count = (end - start) / period (no +1)
const expectedBars = Number((end_time - start_time) / periodMicros);
if (data.length < expectedBars * 0.95) { // Allow 5% tolerance

View File

@@ -39,7 +39,7 @@ export interface IcebergMessage {
id: string;
user_id: string;
session_id: string;
role: 'user' | 'assistant' | 'system';
role: 'user' | 'assistant' | 'system' | 'workspace';
content: string;
metadata: string; // JSON string
timestamp: number; // microseconds

View File

@@ -8,8 +8,9 @@ import type { InboundMessage, OutboundMessage } from '../types/messages.js';
import { MCPClientConnector } from './mcp-client.js';
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
import type { ModelMiddleware } from '../llm/middleware.js';
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
import type { ChannelAdapter } from '../workspace/index.js';
import type { ChannelAdapter, PathTriggerContext } from '../workspace/index.js';
import type { ResearchSubagent } from './subagents/research/index.js';
import type { DynamicStructuredTool } from '@langchain/core/tools';
import { getToolRegistry } from '../tools/tool-registry.js';
@@ -70,10 +71,10 @@ export class AgentHarness {
private config: AgentHarnessConfig;
private modelFactory: LLMProviderFactory;
private modelRouter: ModelRouter;
private middleware: ModelMiddleware | undefined;
private mcpClient: MCPClientConnector;
private workspaceManager?: WorkspaceManager;
private channelAdapter?: ChannelAdapter;
private isFirstMessage: boolean = true;
private researchSubagent?: ResearchSubagent;
private availableMCPTools: MCPToolInfo[] = [];
private researchImageCapture: Array<{ data: string; mimeType: string }> = [];
@@ -94,6 +95,8 @@ export class AgentHarness {
mcpServerUrl: config.mcpServerUrl,
logger: config.logger,
});
this.registerWorkspaceTriggers();
}
/**
@@ -193,7 +196,7 @@ export class AgentHarness {
const { createResearchSubagent } = await import('./subagents/research/index.js');
// Create a model for the research subagent
const model = await this.modelRouter.route(
const { model } = await this.modelRouter.route(
'research analysis', // dummy query
this.config.license,
RoutingStrategy.COMPLEXITY,
@@ -429,11 +432,25 @@ export class AgentHarness {
// 2. Load recent conversation history
const channelKey = this.config.channelType ?? ChannelType.WEBSOCKET;
const storedMessages = this.conversationStore
let storedMessages = this.conversationStore
? await this.conversationStore.getRecentMessages(
this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
)
: [];
// First turn: seed conversation history with current workspace state
if (storedMessages.length === 0 && this.workspaceManager && this.conversationStore) {
const workspaceJSON = this.workspaceManager.serializeState();
const content = `[Workspace State]\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
await this.conversationStore.saveMessage(
this.config.userId, this.config.sessionId,
'workspace', content, { isWorkspaceContext: true }, channelKey
);
storedMessages = await this.conversationStore.getRecentMessages(
this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
);
}
const history = this.conversationStore
? this.conversationStore.toLangChainMessages(storedMessages)
: [];
@@ -441,12 +458,13 @@ export class AgentHarness {
// 4. Get the configured model
this.config.logger.debug('Routing to model');
const model = await this.modelRouter.route(
const { model, middleware } = await this.modelRouter.route(
message.content,
this.config.license,
RoutingStrategy.COMPLEXITY,
this.config.userId
);
this.middleware = middleware;
this.config.logger.info({ modelName: model.constructor.name }, 'Model selected');
// 5. Build LangChain messages
@@ -489,6 +507,11 @@ export class AgentHarness {
'Tools loaded for main agent'
);
// Apply middleware (e.g. Anthropic prompt caching)
const processedMessages = this.middleware
? this.middleware.processMessages(langchainMessages, tools)
: langchainMessages;
// 7. Bind tools to model
const modelWithTools = tools.length > 0 && model.bindTools ? model.bindTools(tools) : model;
@@ -501,7 +524,7 @@ export class AgentHarness {
// 8. Call LLM with tool calling loop
this.config.logger.info('Invoking LLM with tool support');
const assistantMessage = await this.executeWithToolCalling(modelWithTools, langchainMessages, tools);
const assistantMessage = await this.executeWithToolCalling(modelWithTools, processedMessages, tools, 10);
this.config.logger.info(
{ responseLength: assistantMessage.length },
@@ -518,11 +541,6 @@ export class AgentHarness {
);
}
// Mark first message as processed
if (this.isFirstMessage) {
this.isFirstMessage = false;
}
return {
messageId: `msg_${Date.now()}`,
sessionId: message.sessionId,
@@ -556,16 +574,10 @@ export class AgentHarness {
private async buildSystemPrompt(): Promise<string> {
// Load template and populate with license info
const template = await AgentHarness.loadSystemPromptTemplate();
let prompt = template
const prompt = template
.replace('{{licenseType}}', this.config.license.licenseType)
.replace('{{features}}', JSON.stringify(this.config.license.features, null, 2));
// Add full workspace state from WorkspaceManager (first message only)
if (this.isFirstMessage && this.workspaceManager) {
const workspaceJSON = this.workspaceManager.serializeState();
prompt += `\n\n# Current Workspace State\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
}
return prompt;
}
@@ -574,9 +586,14 @@ export class AgentHarness {
*/
private getToolLabel(toolName: string): string {
const labels: Record<string, string> = {
research_agent: 'Researching...',
research: 'Researching...',
get_chart_data: 'Fetching chart data...',
symbol_lookup: 'Looking up symbol...',
category_list: 'Seeing what we have...',
category_edit: 'Coding...',
category_write: 'Coding...',
category_read: 'Inspecting...',
execute_research: 'Running script...',
};
return labels[toolName] ?? `Running ${toolName}...`;
}
@@ -685,6 +702,26 @@ export class AgentHarness {
return result;
}
/**
* Register workspace path triggers to record state changes into conversation history.
*/
private registerWorkspaceTriggers(): void {
if (!this.workspaceManager || !this.conversationStore) return;
const channelKey = this.config.channelType ?? ChannelType.WEBSOCKET;
for (const store of ['shapes', 'indicators', 'chartState']) {
this.workspaceManager.onPathChange(`/${store}/*`, async (_old: unknown, newVal: unknown, ctx: PathTriggerContext) => {
const content = `[Workspace Update] ${ctx.store}${ctx.path}\n${JSON.stringify(newVal, null, 2)}`;
await this.conversationStore!.saveMessage(
this.config.userId, this.config.sessionId,
'workspace', content,
{ isWorkspaceUpdate: true, store: ctx.store, seq: ctx.seq },
channelKey
);
});
}
}
/**
* End the session: flush conversation to cold storage, then release resources.
* Called by channel handlers on disconnect, session expiry, or graceful shutdown.

View File

@@ -11,7 +11,7 @@ export interface StoredMessage {
id: string;
userId: string;
sessionId: string;
role: 'user' | 'assistant' | 'system';
role: 'user' | 'assistant' | 'system' | 'workspace';
content: string;
timestamp: number; // microseconds (Iceberg convention)
metadata?: Record<string, unknown>;
@@ -44,7 +44,7 @@ export class ConversationStore {
async saveMessage(
userId: string,
sessionId: string,
role: 'user' | 'assistant' | 'system',
role: 'user' | 'assistant' | 'system' | 'workspace',
content: string,
metadata?: Record<string, unknown>,
channelType?: string
@@ -171,6 +171,8 @@ export class ConversationStore {
return new AIMessage(msg.content);
case 'system':
return new SystemMessage(msg.content);
case 'workspace':
return new HumanMessage(msg.content);
default:
throw new Error(`Unknown role: ${msg.role}`);
}

View File

@@ -59,6 +59,11 @@ Example usage:
- User: "Does Friday price action correlate with Monday?"
- You: Call research tool with instruction="Analyze correlation between Friday and Monday price action during NY trading hours (9:30-4:00 ET)", name="Friday-Monday Correlation"
### category_list
List existing research scripts (category="research").
Use this before calling the research tool to check whether a relevant script already exists.
If one does, pass its exact name to the research tool so the subagent updates it rather than creating a new one.
### symbol-lookup
Look up trading symbols and get metadata.
Use this when users mention tickers or need symbol information.

View File

@@ -12,6 +12,7 @@ maxTokens: 8192
memoryFiles:
- api-reference.md
- usage-examples.md
- pandas-ta-reference.md
# System prompt file
systemPromptFile: system-prompt.md

View File

@@ -0,0 +1,227 @@
# pandas-ta Reference for Research Scripts
The sandbox environment uses **pandas-ta** as the standard indicator library. Always use it for technical indicator calculations; do not write manual rolling/ewm implementations.
```python
import pandas_ta as ta
```
## Calling Convention
pandas-ta functions accept a Series (or OHLCV columns) plus keyword parameters that match pandas-ta's documented argument names:
```python
# Single-series indicator
rsi = ta.rsi(df['close'], length=14) # returns Series
# OHLCV indicator
atr = ta.atr(df['high'], df['low'], df['close'], length=14)
# Multi-output indicator (returns DataFrame)
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
# columns: MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
bbands_df = ta.bbands(df['close'], length=20, std=2.0)
# columns: BBL_20_2.0, BBM_20_2.0, BBU_20_2.0, BBB_20_2.0, BBP_20_2.0
```
## Default Parameters
Key defaults to keep in mind:
- Most period/length indicators: `length=14` (use `length=` not `timeperiod=`)
- `bbands`: `length=20, std=2.0` (note: single `std`, not separate upper/lower)
- `macd`: `fast=12, slow=26, signal=9`
- `stoch`: `k=14, d=3, smooth_k=3`
- `psar`: `af0=0.02, af=0.02, max_af=0.2`
- `vwap`: `anchor='D'` (requires DatetimeIndex)
- `ichimoku`: `tenkan=9, kijun=26, senkou=52`
## Available Indicators
These match the indicators supported by the TradingView web client. Use the pandas-ta function name shown here (lowercase):
### Overlap / Moving Averages — plotted on the price pane
| Function | Description |
|----------|-------------|
| `sma` | Simple Moving Average — plain arithmetic mean over `length` periods |
| `ema` | Exponential Moving Average — more weight on recent prices |
| `wma` | Weighted Moving Average — linearly increasing weights |
| `dema` | Double EMA — two layers of EMA to reduce lag |
| `tema` | Triple EMA — three layers of EMA, even less lag than DEMA |
| `trima` | Triangular MA — double-smoothed SMA, very smooth |
| `kama` | Kaufman Adaptive MA — adapts speed to market noise/trending conditions |
| `t3` | T3 Moving Average — Tillson's smooth, low-lag MA using six EMAs |
| `hma` | Hull MA — very low-lag MA using WMAs |
| `alma` | Arnaud Legoux MA — Gaussian-weighted MA with reduced lag and noise |
| `midpoint` | Midpoint of close over `length` periods: (highest + lowest) / 2 |
| `midprice` | Midpoint of high/low over `length` periods |
| `supertrend` | Trend-following band (ATR-based) that flips above/below price |
| `ichimoku` | Ichimoku Cloud — multi-line Japanese trend/support/resistance system |
| `vwap` | Volume-Weighted Average Price — average price weighted by volume, resets on `anchor` |
| `vwma` | Volume-Weighted MA — like SMA but candles weighted by volume |
| `bbands` | Bollinger Bands — SMA ± N standard deviations; returns upper, mid, lower bands |
### Momentum — typically plotted in a separate pane
| Function | Description |
|----------|-------------|
| `rsi` | Relative Strength Index — 0100 oscillator measuring speed of price changes |
| `macd` | MACD — difference of two EMAs plus signal line and histogram |
| `stoch` | Stochastic Oscillator — %K/%D, measures close vs recent high/low range |
| `stochrsi` | Stochastic RSI — applies stochastic formula to RSI values |
| `cci` | Commodity Channel Index — deviation of price from its statistical mean |
| `willr` | Williams %R — inverse stochastic, 100 to 0 oscillator |
| `mom` | Momentum — raw price change over `length` periods |
| `roc` | Rate of Change — percentage price change over `length` periods |
| `trix` | TRIX — 1-period % change of a triple-smoothed EMA |
| `cmo` | Chande Momentum Oscillator — ratio of up/down momentum, 100 to 100 |
| `adx` | Average Directional Index — strength of trend (0100, direction-agnostic) |
| `aroon` | Aroon — measures how recently the highest/lowest price occurred; returns Up, Down, Oscillator |
| `ao` | Awesome Oscillator — difference of 5- and 34-period simple MAs of midprice |
| `bop` | Balance of Power — measures buying vs selling pressure: (closeopen)/(highlow) |
| `uo` | Ultimate Oscillator — weighted combo of three period (fast/medium/slow) buying pressure ratios |
| `apo` | Absolute Price Oscillator — difference between two EMAs (like MACD without signal line) |
| `mfi` | Money Flow Index — RSI-like oscillator using price × volume |
| `coppock` | Coppock Curve — long-term momentum oscillator based on rate-of-change |
| `dpo` | Detrended Price Oscillator — removes trend to show cycle oscillations |
| `fisher` | Fisher Transform — converts price into a Gaussian normal distribution |
| `rvgi` | Relative Vigor Index — compares closeopen to highlow to measure trend vigor |
| `kst` | Know Sure Thing — momentum oscillator from four ROC periods, smoothed |
### Volatility — plotted on price pane or separate
| Function | Description |
|----------|-------------|
| `atr` | Average True Range — average of true range (greatest of HL, HprevC, LprevC) |
| `kc` | Keltner Channels — EMA ± N × ATR bands around price |
| `donchian` | Donchian Channels — highest high / lowest low over `length` periods |
### Volume — plotted in separate pane
| Function | Description |
|----------|-------------|
| `obv` | On Balance Volume — cumulative volume, added on up days, subtracted on down days |
| `ad` | Accumulation/Distribution — running total of the money flow multiplier × volume |
| `adosc` | Chaikin Oscillator — EMA difference of the A/D line |
| `cmf` | Chaikin Money Flow — sum of (money flow volume) / sum of volume over `length` |
| `eom` | Ease of Movement — relates price change to volume; high = price moves easily |
| `efi` | Elder's Force Index — combines price change direction with volume magnitude |
| `kvo` | Klinger Volume Oscillator — EMA difference of volume force |
| `pvt` | Price Volume Trend — cumulative: volume × percentage price change |
### Statistics / Price Transforms
| Function | Description |
|----------|-------------|
| `stdev` | Standard Deviation of close over `length` periods |
| `linreg` | Linear Regression Curve — least-squares line endpoint value over `length` periods |
| `slope` | Linear Regression Slope — gradient of the regression line |
| `hl2` | Median Price — (high + low) / 2 |
| `hlc3` | Typical Price — (high + low + close) / 3 |
| `ohlc4` | Average Price — (open + high + low + close) / 4 |
### Trend
| Function | Description |
|----------|-------------|
| `psar` | Parabolic SAR — trailing stop-and-reverse dots that follow price |
| `vortex` | Vortex Indicator — VI+ / VI lines measuring upward vs downward trend movement |
| `chop` | Choppiness Index — 0100, high = choppy/sideways, low = strong trend |
## Usage Examples
### Single-output indicators
```python
import pandas_ta as ta
df['rsi'] = ta.rsi(df['close'], length=14)
df['ema_20'] = ta.ema(df['close'], length=20)
df['sma_50'] = ta.sma(df['close'], length=50)
df['atr'] = ta.atr(df['high'], df['low'], df['close'], length=14)
df['obv'] = ta.obv(df['close'], df['volume'])
df['adx'] = ta.adx(df['high'], df['low'], df['close'], length=14)['ADX_14']
```
### Multi-output indicators — extract columns by position
```python
# MACD → MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
df['macd'] = macd_df.iloc[:, 0] # MACD line
df['macd_hist'] = macd_df.iloc[:, 1] # Histogram
df['macd_signal'] = macd_df.iloc[:, 2] # Signal line
# Bollinger Bands → BBL, BBM, BBU, BBB, BBP
bb_df = ta.bbands(df['close'], length=20, std=2.0)
df['bb_lower'] = bb_df.iloc[:, 0] # BBL
df['bb_mid'] = bb_df.iloc[:, 1] # BBM
df['bb_upper'] = bb_df.iloc[:, 2] # BBU
# Stochastic → STOCHk, STOCHd
stoch_df = ta.stoch(df['high'], df['low'], df['close'], k=14, d=3, smooth_k=3)
df['stoch_k'] = stoch_df.iloc[:, 0]
df['stoch_d'] = stoch_df.iloc[:, 1]
# Keltner Channels → KCLe, KCBe, KCUe
kc_df = ta.kc(df['high'], df['low'], df['close'], length=20)
df['kc_lower'] = kc_df.iloc[:, 0]
df['kc_mid'] = kc_df.iloc[:, 1]
df['kc_upper'] = kc_df.iloc[:, 2]
# ADX → ADX_14, DMP_14, DMN_14
adx_df = ta.adx(df['high'], df['low'], df['close'], length=14)
df['adx'] = adx_df.iloc[:, 0] # ADX strength
df['dmp'] = adx_df.iloc[:, 1] # +DI
df['dmn'] = adx_df.iloc[:, 2] # -DI
# Aroon → AROOND_14, AROONU_14, AROONOSC_14
aroon_df = ta.aroon(df['high'], df['low'], length=14)
df['aroon_down'] = aroon_df.iloc[:, 0]
df['aroon_up'] = aroon_df.iloc[:, 1]
# Donchian Channels → DCL, DCM, DCU
dc_df = ta.donchian(df['high'], df['low'], lower_length=20, upper_length=20)
df['dc_lower'] = dc_df.iloc[:, 0]
df['dc_mid'] = dc_df.iloc[:, 1]
df['dc_upper'] = dc_df.iloc[:, 2]
```
### Charting with indicators
```python
import pandas_ta as ta
from dexorder.api import get_api
import asyncio
api = get_api()
df = asyncio.run(api.data.historical_ohlc(
ticker="BINANCE:BTC/USDT",
period_seconds=3600,
start_time="2024-01-01",
end_time="2024-01-08",
extra_columns=["volume"]
))
# Compute indicators
df['ema_20'] = ta.ema(df['close'], length=20)
df['rsi'] = ta.rsi(df['close'], length=14)
macd_df = ta.macd(df['close'])
df['macd'] = macd_df.iloc[:, 0]
df['macd_signal'] = macd_df.iloc[:, 2]
# Main price chart with EMA overlay
fig, ax = api.charting.plot_ohlc(df, title="BTC/USDT 1H", volume=True)
ax.plot(df.index, df['ema_20'], label="EMA 20", color="orange", linewidth=1.5)
ax.legend()
# RSI panel
rsi_ax = api.charting.add_indicator_panel(fig, df, columns=["rsi"], ylabel="RSI", ylim=(0, 100))
rsi_ax.axhline(70, color='red', linestyle='--', alpha=0.5)
rsi_ax.axhline(30, color='green', linestyle='--', alpha=0.5)
# MACD panel
api.charting.add_indicator_panel(fig, df, columns=["macd", "macd_signal"], ylabel="MACD")
```

View File

@@ -112,10 +112,12 @@ fig, ax = api.charting.plot_ohlc(
### Adding Indicator Panels
Use **pandas-ta** for all indicator calculations. Do not write manual rolling/ewm implementations.
```python
from dexorder.api import get_api
import asyncio
import pandas as pd
import pandas_ta as ta
api = get_api()
@@ -127,8 +129,9 @@ df = asyncio.run(api.data.historical_ohlc(
end_time="2021-12-21"
))
# Calculate a simple moving average
df['sma_20'] = df['close'].rolling(window=20).mean()
# Calculate indicators using pandas-ta
df['sma_20'] = ta.sma(df['close'], length=20)
df['rsi'] = ta.rsi(df['close'], length=14)
# Create chart
fig, ax = api.charting.plot_ohlc(df, title="BTC/USDT with SMA")
@@ -138,7 +141,6 @@ ax.plot(df.index, df['sma_20'], label="SMA 20", color="blue", linewidth=2)
ax.legend()
# Add RSI indicator panel below
df['rsi'] = calculate_rsi(df['close'], 14) # Your RSI calculation
rsi_ax = api.charting.add_indicator_panel(
fig, df,
columns=["rsi"],
@@ -149,12 +151,40 @@ rsi_ax.axhline(70, color='red', linestyle='--', alpha=0.5)
rsi_ax.axhline(30, color='green', linestyle='--', alpha=0.5)
```
### Multi-Output Indicators
Some pandas-ta indicators return a DataFrame. Extract the columns you need:
```python
import pandas_ta as ta
# MACD returns: MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
df['macd'] = macd_df.iloc[:, 0] # MACD line
df['macd_hist'] = macd_df.iloc[:, 1] # Histogram
df['macd_signal'] = macd_df.iloc[:, 2] # Signal line
# Bollinger Bands returns: BBL, BBM, BBU, BBB, BBP
bb_df = ta.bbands(df['close'], length=20, std=2.0)
df['bb_upper'] = bb_df.iloc[:, 2] # BBU
df['bb_mid'] = bb_df.iloc[:, 1] # BBM
df['bb_lower'] = bb_df.iloc[:, 0] # BBL
# Stochastic returns: STOCHk, STOCHd
stoch_df = ta.stoch(df['high'], df['low'], df['close'], k=14, d=3, smooth_k=3)
df['stoch_k'] = stoch_df.iloc[:, 0]
df['stoch_d'] = stoch_df.iloc[:, 1]
# ATR (uses high, low, close)
df['atr'] = ta.atr(df['high'], df['low'], df['close'], length=14)
```
## Complete Example
```python
from dexorder.api import get_api
import asyncio
import pandas as pd
import pandas_ta as ta
# Get API instance
api = get_api()
@@ -168,9 +198,9 @@ df = asyncio.run(api.data.historical_ohlc(
extra_columns=["volume"]
))
# Add some analysis
df['sma_20'] = df['close'].rolling(window=20).mean()
df['sma_50'] = df['close'].rolling(window=50).mean()
# Add moving averages using pandas-ta
df['sma_20'] = ta.sma(df['close'], length=20)
df['ema_50'] = ta.ema(df['close'], length=50)
# Create chart with volume
fig, ax = api.charting.plot_ohlc(
@@ -182,7 +212,7 @@ fig, ax = api.charting.plot_ohlc(
# Overlay moving averages
ax.plot(df.index, df['sma_20'], label="SMA 20", color="blue", linewidth=1.5)
ax.plot(df.index, df['sma_50'], label="SMA 50", color="red", linewidth=1.5)
ax.plot(df.index, df['ema_50'], label="EMA 50", color="red", linewidth=1.5)
ax.legend()
# Print summary statistics

View File

@@ -51,7 +51,140 @@ The API provides two main components:
- `api.data` - DataAPI for fetching OHLC market data
- `api.charting` - ChartingAPI for creating financial charts
See your knowledge base for complete API documentation and examples.
See your knowledge base for complete API documentation, examples, and the full pandas-ta indicator reference (see `pandas-ta-reference.md`).
## Technical Indicators — pandas-ta
The sandbox environment uses **pandas-ta** as the standard indicator library. Always use it for technical indicator calculations; do not write manual rolling/ewm implementations.
```python
import pandas_ta as ta
```
### Calling Convention
pandas-ta functions accept a Series (or OHLCV columns) plus keyword parameters that match pandas-ta's documented argument names:
```python
# Single-series indicator
rsi = ta.rsi(df['close'], length=14) # returns Series
# OHLCV indicator
atr = ta.atr(df['high'], df['low'], df['close'], length=14)
# Multi-output indicator (returns DataFrame)
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
# columns: MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
bbands_df = ta.bbands(df['close'], length=20, std=2.0)
# columns: BBL_20_2.0, BBM_20_2.0, BBU_20_2.0, BBB_20_2.0, BBP_20_2.0
```
### Available Indicators (canonical list)
These match the indicators supported by the TradingView web client. Use the pandas-ta function name shown here (lowercase):
**Overlap / Moving Averages** — plotted on the price pane
| Function | Description |
|----------|-------------|
| `sma` | Simple Moving Average — plain arithmetic mean over `length` periods |
| `ema` | Exponential Moving Average — more weight on recent prices |
| `wma` | Weighted Moving Average — linearly increasing weights |
| `dema` | Double EMA — two layers of EMA to reduce lag |
| `tema` | Triple EMA — three layers of EMA, even less lag than DEMA |
| `trima` | Triangular MA — double-smoothed SMA, very smooth |
| `kama` | Kaufman Adaptive MA — adapts speed to market noise/trending conditions |
| `t3` | T3 Moving Average — Tillson's smooth, low-lag MA using six EMAs |
| `hma` | Hull MA — very low-lag MA using WMAs |
| `alma` | Arnaud Legoux MA — Gaussian-weighted MA with reduced lag and noise |
| `midpoint` | Midpoint of close over `length` periods: (highest + lowest) / 2 |
| `midprice` | Midpoint of high/low over `length` periods |
| `supertrend` | Trend-following band (ATR-based) that flips above/below price |
| `ichimoku` | Ichimoku Cloud — multi-line Japanese trend/support/resistance system |
| `vwap` | Volume-Weighted Average Price — average price weighted by volume, resets on `anchor` |
| `vwma` | Volume-Weighted MA — like SMA but candles weighted by volume |
| `bbands` | Bollinger Bands — SMA ± N standard deviations; returns upper, mid, lower bands |
**Momentum** — typically plotted in a separate pane
| Function | Description |
|----------|-------------|
| `rsi` | Relative Strength Index — 0100 oscillator measuring speed of price changes |
| `macd` | MACD — difference of two EMAs plus signal line and histogram |
| `stoch` | Stochastic Oscillator — %K/%D, measures close vs recent high/low range |
| `stochrsi` | Stochastic RSI — applies stochastic formula to RSI values |
| `cci` | Commodity Channel Index — deviation of price from its statistical mean |
| `willr` | Williams %R — inverse stochastic, 100 to 0 oscillator |
| `mom` | Momentum — raw price change over `length` periods |
| `roc` | Rate of Change — percentage price change over `length` periods |
| `trix` | TRIX — 1-period % change of a triple-smoothed EMA |
| `cmo` | Chande Momentum Oscillator — ratio of up/down momentum, 100 to 100 |
| `adx` | Average Directional Index — strength of trend (0100, direction-agnostic) |
| `aroon` | Aroon — measures how recently the highest/lowest price occurred; returns Up, Down, Oscillator |
| `ao` | Awesome Oscillator — difference of 5- and 34-period simple MAs of midprice |
| `bop` | Balance of Power — measures buying vs selling pressure: (closeopen)/(highlow) |
| `uo` | Ultimate Oscillator — weighted combo of three period (fast/medium/slow) buying pressure ratios |
| `apo` | Absolute Price Oscillator — difference between two EMAs (like MACD without signal line) |
| `mfi` | Money Flow Index — RSI-like oscillator using price × volume |
| `coppock` | Coppock Curve — long-term momentum oscillator based on rate-of-change |
| `dpo` | Detrended Price Oscillator — removes trend to show cycle oscillations |
| `fisher` | Fisher Transform — converts price into a Gaussian normal distribution |
| `rvgi` | Relative Vigor Index — compares closeopen to highlow to measure trend vigor |
| `kst` | Know Sure Thing — momentum oscillator from four ROC periods, smoothed |
**Volatility** — plotted on price pane or separate
| Function | Description |
|----------|-------------|
| `atr` | Average True Range — average of true range (greatest of HL, HprevC, LprevC) |
| `kc` | Keltner Channels — EMA ± N × ATR bands around price |
| `donchian` | Donchian Channels — highest high / lowest low over `length` periods |
**Volume** — plotted in separate pane
| Function | Description |
|----------|-------------|
| `obv` | On Balance Volume — cumulative volume, added on up days, subtracted on down days |
| `ad` | Accumulation/Distribution — running total of the money flow multiplier × volume |
| `adosc` | Chaikin Oscillator — EMA difference of the A/D line |
| `cmf` | Chaikin Money Flow — sum of (money flow volume) / sum of volume over `length` |
| `eom` | Ease of Movement — relates price change to volume; high = price moves easily |
| `efi` | Elder's Force Index — combines price change direction with volume magnitude |
| `kvo` | Klinger Volume Oscillator — EMA difference of volume force |
| `pvt` | Price Volume Trend — cumulative: volume × percentage price change |
**Statistics / Price Transforms**
| Function | Description |
|----------|-------------|
| `stdev` | Standard Deviation of close over `length` periods |
| `linreg` | Linear Regression Curve — least-squares line endpoint value over `length` periods |
| `slope` | Linear Regression Slope — gradient of the regression line |
| `hl2` | Median Price — (high + low) / 2 |
| `hlc3` | Typical Price — (high + low + close) / 3 |
| `ohlc4` | Average Price — (open + high + low + close) / 4 |
**Trend**
| Function | Description |
|----------|-------------|
| `psar` | Parabolic SAR — trailing stop-and-reverse dots that follow price |
| `vortex` | Vortex Indicator — VI+ / VI lines measuring upward vs downward trend movement |
| `chop` | Choppiness Index — 0100, high = choppy/sideways, low = strong trend |
### Default Parameters
Key defaults to keep in mind:
- Most period/length indicators: `length=14` (use `length=` not `timeperiod=`)
- `bbands`: `length=20, std=2.0` (note: single `std`, not separate upper/lower)
- `macd`: `fast=12, slow=26, signal=9`
- `stoch`: `k=14, d=3, smooth_k=3`
- `psar`: `af0=0.02, af=0.02, max_af=0.2`
- `vwap`: `anchor='D'` (requires DatetimeIndex)
- `ichimoku`: `tenkan=9, kijun=26, senkou=52`
For multi-output indicator column extraction patterns and complete charting examples, fetch `pandas-ta-reference.md` from your knowledge base.
## Coding Loop Pattern
@@ -59,11 +192,9 @@ When a user requests analysis:
1. **Understand the request**: What data is needed? What analysis? What visualization?
2. **Check for existing scripts**: Use `category_list` to see if a similar script exists
- If exists and suitable: use `category_read` to review it
- Consider editing existing script vs creating new one
2. **Use the provided name**: The instruction will begin with `Research script name: "<name>"`. Always use that exact name when calling `category_write` or `category_edit`. Check first with `category_read` — if the script already exists, use `category_edit` to update it rather than creating a new one with `category_write`.
3. **Write the script**: Use `category_write` (or `category_edit`)
3. **Write the script**: Use `category_write` (new) or `category_edit` (existing)
- Write clean, well-commented Python code
- Include proper error handling
- Use appropriate ticker symbols, time ranges, and periods
@@ -106,10 +237,7 @@ When a user requests analysis:
- Add `conda_packages: ["package-name"]` to metadata
- Packages are auto-installed during validation
- **Script naming**: Choose descriptive, unique names. Examples:
- "BTC Weekly Analysis"
- "ETH Volume Profile"
- "Market Correlation Heatmap"
- **Script naming**: Always use the name provided in the instruction (`Research script name: "<name>"`). Do not invent a different name.
- **Error handling**: Wrap data fetching in try/except to provide helpful error messages

View File

@@ -0,0 +1,89 @@
import type { BaseMessage } from '@langchain/core/messages';
import { SystemMessage, HumanMessage, AIMessage } from '@langchain/core/messages';
import type { StructuredTool } from '@langchain/core/tools';
/**
* Provider-agnostic hook to preprocess messages before an LLM call.
* Applied transparently by the harness; implementations are provider-specific.
*/
export interface ModelMiddleware {
processMessages(messages: BaseMessage[], tools: StructuredTool[]): BaseMessage[];
}
/**
* No-op implementation for providers that don't support prompt caching.
*/
export class NoopMiddleware implements ModelMiddleware {
processMessages(messages: BaseMessage[]): BaseMessage[] {
return messages;
}
}
/**
* Mirrors Python's AnthropicPromptCachingMiddleware logic.
*
* Tags with cache_control: { type: 'ephemeral' }:
* 1. The system message last content block (stable prompt prefix — always a cache hit after turn 1)
* 2. The last non-current cacheable message (AIMessage or HumanMessage before the final user message)
* so the full conversation prefix is cached on the next turn.
*
* Requires ChatAnthropic to be configured with:
* clientOptions: { defaultHeaders: { 'anthropic-beta': 'prompt-caching-2024-07-31' } }
*/
export class AnthropicCachingMiddleware implements ModelMiddleware {
processMessages(messages: BaseMessage[], _tools: StructuredTool[]): BaseMessage[] {
if (messages.length === 0) return messages;
const result = messages.map(msg => cloneMessage(msg));
// 1. Tag system message
const systemMsg = result.find(m => m._getType() === 'system');
if (systemMsg) {
addCacheControl(systemMsg);
}
// 2. Tag the last cacheable message that isn't the current user input.
// The current user message is always the last element; we want the one before it.
// We look backwards for the last AIMessage or HumanMessage (excluding the final message).
const candidates = result.slice(0, -1);
for (let i = candidates.length - 1; i >= 0; i--) {
const t = candidates[i]._getType();
if (t === 'ai' || t === 'human') {
addCacheControl(candidates[i]);
break;
}
}
return result;
}
}
/**
* Shallow-clone a message so we don't mutate history objects.
*/
function cloneMessage(msg: BaseMessage): BaseMessage {
const type = msg._getType();
const content = typeof msg.content === 'string'
? msg.content
: JSON.parse(JSON.stringify(msg.content));
if (type === 'system') return new SystemMessage({ content, additional_kwargs: { ...msg.additional_kwargs } });
if (type === 'human') return new HumanMessage({ content, additional_kwargs: { ...msg.additional_kwargs } });
if (type === 'ai') return new AIMessage({ content, additional_kwargs: { ...msg.additional_kwargs }, tool_calls: (msg as AIMessage).tool_calls });
// For other types (tool messages etc.), return as-is — we don't tag them
return msg;
}
/**
* Add cache_control to the last content block of a message.
* Converts string content to a block array if needed.
*/
function addCacheControl(msg: BaseMessage): void {
if (typeof msg.content === 'string') {
// Convert to block array
(msg as any).content = [{ type: 'text', text: msg.content, cache_control: { type: 'ephemeral' } }];
} else if (Array.isArray(msg.content) && msg.content.length > 0) {
const last = msg.content[msg.content.length - 1] as any;
last.cache_control = { type: 'ephemeral' };
}
}

View File

@@ -1,6 +1,10 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatAnthropic } from '@langchain/anthropic';
import type { FastifyBaseLogger } from 'fastify';
import { type ModelMiddleware, NoopMiddleware, AnthropicCachingMiddleware } from './middleware.js';
export type { ModelMiddleware };
export { NoopMiddleware, AnthropicCachingMiddleware };
/**
* Supported LLM providers
@@ -64,7 +68,7 @@ export class LLMProviderFactory {
/**
* Create a chat model instance
*/
createModel(modelConfig: ModelConfig): BaseChatModel {
createModel(modelConfig: ModelConfig): { model: BaseChatModel; middleware: ModelMiddleware } {
this.logger.debug(
{ provider: modelConfig.provider, model: modelConfig.model },
'Creating LLM model'
@@ -82,17 +86,20 @@ export class LLMProviderFactory {
/**
* Create Anthropic Claude model
*/
private createAnthropicModel(config: ModelConfig): ChatAnthropic {
private createAnthropicModel(config: ModelConfig): { model: ChatAnthropic; middleware: AnthropicCachingMiddleware } {
if (!this.config.anthropicApiKey) {
throw new Error('Anthropic API key not configured');
}
return new ChatAnthropic({
const model = new ChatAnthropic({
model: config.model,
temperature: config.temperature ?? 0.7,
maxTokens: config.maxTokens ?? 4096,
anthropicApiKey: this.config.anthropicApiKey,
clientOptions: { defaultHeaders: { 'anthropic-beta': 'prompt-caching-2024-07-31' } },
});
return { model, middleware: new AnthropicCachingMiddleware() };
}
/**

View File

@@ -1,6 +1,7 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
import { LLMProviderFactory, type ModelConfig, LLMProvider, type LicenseModelsConfig } from './provider.js';
import type { ModelMiddleware } from './middleware.js';
import type { License } from '../types/user.js';
/**
@@ -42,7 +43,7 @@ export class ModelRouter {
license: License,
strategy: RoutingStrategy = RoutingStrategy.USER_PREFERENCE,
userId?: string
): Promise<BaseChatModel> {
): Promise<{ model: BaseChatModel; middleware: ModelMiddleware }> {
let modelConfig: ModelConfig;
switch (strategy) {

View File

@@ -586,7 +586,7 @@ try {
toolRegistry.registerAgentTools({
agentName: 'main',
platformTools: ['symbol_lookup', 'get_chart_data'],
mcpTools: [], // No MCP tools for main agent by default (can be extended later)
mcpTools: ['category_list'], // category_list lets the main agent see existing research scripts
});
// Research subagent: only MCP tools for script creation/execution

View File

@@ -27,7 +27,6 @@ import type {
import {
secondsToMicros,
backendToTradingView,
resolutionToSeconds,
DEFAULT_SUPPORTED_RESOLUTIONS,
} from '../types/ohlc.js';
@@ -67,25 +66,32 @@ export class OHLCService {
*/
async fetchOHLC(
ticker: string,
resolution: string,
period_seconds: number,
from_time: number, // Unix timestamp in SECONDS
to_time: number, // Unix timestamp in SECONDS
countback?: number
): Promise<HistoryResult> {
this.logger.debug({
ticker,
resolution,
period_seconds,
from_time,
to_time,
countback,
}, 'Fetching OHLC data');
// Convert resolution to period_seconds
const period_seconds = resolutionToSeconds(resolution);
// Convert times to microseconds
const start_time = secondsToMicros(from_time);
const end_time = secondsToMicros(to_time);
// Convert times to microseconds, then align to period boundaries using
// [ceil(start), ceil(end)) semantics:
// - start: ceil to next period boundary — excludes any in-progress candle whose
// official timestamp is before from_time.
// - end: ceil to next period boundary, used as EXCLUSIVE upper bound — includes
// the last candle whose timestamp < to_time, excludes one sitting exactly on
// to_time (which would be the next candle, not yet started).
const periodMicros = BigInt(period_seconds) * 1_000_000n;
const raw_start = secondsToMicros(from_time);
const raw_end = secondsToMicros(to_time);
// bigint ceiling: ceil(a/b)*b = ((a + b - 1) / b) * b
const start_time = ((raw_start + periodMicros - 1n) / periodMicros) * periodMicros;
const end_time = ((raw_end + periodMicros - 1n) / periodMicros) * periodMicros; // exclusive
// Step 1: Check Iceberg for existing data
let data = await this.icebergClient.queryOHLC(ticker, period_seconds, start_time, end_time);
@@ -100,25 +106,26 @@ export class OHLCService {
if (missingRanges.length === 0 && data.length > 0) {
// All data exists in Iceberg
this.logger.debug({ ticker, resolution, cached: true }, 'OHLC data found in cache');
return this.formatHistoryResult(data, countback);
this.logger.debug({ ticker, period_seconds, cached: true }, 'OHLC data found in cache');
return this.formatHistoryResult(data, start_time, end_time, period_seconds, countback);
}
// Step 3: Request missing data via relay
this.logger.debug({ ticker, resolution, missingRanges: missingRanges.length }, 'Requesting missing OHLC data');
this.logger.debug({ ticker, period_seconds, missingRanges: missingRanges.length }, 'Requesting missing OHLC data');
try {
const notification = await this.relayClient.requestHistoricalOHLC(
ticker,
period_seconds,
start_time,
end_time,
countback
end_time
// countback is NOT passed as a limit — the ingestor must fetch the full range.
// Countback is applied below after we have the complete dataset.
);
this.logger.info({
ticker,
resolution,
period_seconds,
row_count: notification.row_count,
status: notification.status,
}, 'Historical data request completed');
@@ -126,13 +133,13 @@ export class OHLCService {
// Step 4: Query Iceberg again for complete dataset
data = await this.icebergClient.queryOHLC(ticker, period_seconds, start_time, end_time);
return this.formatHistoryResult(data, countback);
return this.formatHistoryResult(data, start_time, end_time, period_seconds, countback);
} catch (error: any) {
this.logger.error({
error,
ticker,
resolution,
period_seconds,
}, 'Failed to fetch historical data');
// Return empty result on error
@@ -144,9 +151,22 @@ export class OHLCService {
}
/**
* Format OHLC data as TradingView history result
* Format OHLC data as TradingView history result.
*
* Interior gaps (confirmed trading periods with no trades) arrive as null-OHLC
* rows from Iceberg. Edge gaps (data not yet ingested, in-progress candles) are
* simply absent rows. Both are returned as-is; clients fill as appropriate.
*/
private formatHistoryResult(data: any[], countback?: number): HistoryResult {
private formatHistoryResult(
data: any[],
// @ts-ignore
start_time: bigint,
// @ts-ignore
end_time: bigint,
// @ts-ignore
period_seconds: number,
countback?: number
): HistoryResult {
if (data.length === 0) {
return {
bars: [],
@@ -154,13 +174,11 @@ export class OHLCService {
};
}
// Convert to TradingView format
// Convert to TradingView format without null-filling missing slots.
let bars: TradingViewBar[] = data.map(backendToTradingView);
// Sort by time
bars.sort((a, b) => a.time - b.time);
// Apply countback limit if specified
if (countback && bars.length > countback) {
bars = bars.slice(-countback);
}

View File

@@ -52,7 +52,7 @@ Parameters:
// Build request with workspace defaults
const finalTicker = ticker ?? chartState.symbol;
const finalPeriod = period ?? parsePeriod(chartState.period);
const finalPeriod = period ?? chartState.period;
const finalFromTime = await parseTime(from_time, chartState.start_time, logger);
const finalToTime = await parseTime(to_time, chartState.end_time, logger);
const requestedColumns = columns ?? [];
@@ -83,7 +83,7 @@ Parameters:
// Fetch data from OHLCService
const historyResult = await ohlcService.fetchOHLC(
finalTicker,
finalPeriod.toString(),
finalPeriod,
finalFromTime,
finalToTime,
countback
@@ -167,7 +167,7 @@ async function getChartState(workspaceManager: WorkspaceManager, logger: Fastify
symbol: 'BINANCE:BTC/USDT',
start_time: null,
end_time: null,
period: '15',
period: 900,
selected_shapes: [],
};
}
@@ -180,35 +180,12 @@ async function getChartState(workspaceManager: WorkspaceManager, logger: Fastify
symbol: 'BINANCE:BTC/USDT',
start_time: null,
end_time: null,
period: '15',
period: 900,
selected_shapes: [],
};
}
}
/**
* Parse period string to seconds
* Handles period as either a number (already in seconds) or string (minutes)
*/
function parsePeriod(period: string | number | null): number | null {
if (period === null) {
return null;
}
if (typeof period === 'number') {
return period;
}
// Period in workspace is stored as string representing minutes
// Convert to seconds
const minutes = parseInt(period, 10);
if (isNaN(minutes)) {
return null;
}
return minutes * 60;
}
/**
* Parse time parameter (Unix seconds, date string, or null)
* Returns Unix timestamp in seconds

View File

@@ -30,13 +30,16 @@ Use this tool for:
The research subagent will write and execute Python scripts, capture output and charts, and return results.`,
schema: z.object({
name: z.string().describe('The name of the research script to create or update (e.g. "btc_ema_analysis"). Use the same name across calls to revise the same script rather than creating a new one.'),
instruction: z.string().describe('The research task or analysis to perform. Be specific about what data, indicators, timeframes, and output you want.'),
}),
func: async ({ instruction }: { instruction: string }): Promise<string> => {
logger.info({ instruction: instruction.substring(0, 100) }, 'Delegating to research subagent');
func: async ({ name, instruction }: { name: string; instruction: string }): Promise<string> => {
logger.info({ name, instruction: instruction.substring(0, 100) }, 'Delegating to research subagent');
const prompt = `Research script name: "${name}"\n\n${instruction}`;
try {
const result = await researchSubagent.executeWithImages(context, instruction);
const result = await researchSubagent.executeWithImages(context, prompt);
// Return in the format that AgentHarness.processToolResult() knows how to handle
// (extracts images and passes them to channelAdapter)

View File

@@ -11,12 +11,12 @@
* TradingView bar format (used by web frontend)
*/
export interface TradingViewBar {
time: number; // Unix timestamp in SECONDS
open: number;
high: number;
low: number;
close: number;
volume?: number;
time: number; // Unix timestamp in SECONDS
open: number | null; // null for gap bars (no trades that period)
high: number | null;
low: number | null;
close: number | null;
volume?: number | null;
// Optional extra columns from ohlc.proto
buy_vol?: number;
sell_vol?: number;
@@ -31,14 +31,14 @@ export interface TradingViewBar {
* Backend OHLC format (from Iceberg)
*/
export interface BackendOHLC {
timestamp: number; // Unix timestamp in MICROSECONDS
timestamp: bigint; // Unix timestamp in MICROSECONDS — kept as bigint to preserve precision
ticker: string;
period_seconds: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
open: number | null; // null for gap bars (no trades that period)
high: number | null;
low: number | null;
close: number | null;
volume: number | null;
}
/**
@@ -171,7 +171,7 @@ export function backendToTradingView(backend: BackendOHLC): TradingViewBar {
high: backend.high,
low: backend.low,
close: backend.close,
volume: backend.volume,
volume: backend.volume ?? undefined,
};
}

View File

@@ -110,11 +110,33 @@ class SyncEntry {
return this.history.filter((entry) => entry.seq > sinceSeq);
}
/**
* Ensure intermediate objects exist for all patch add/replace operations.
* Called before applying patches to gracefully handle paths into previously-absent sub-objects.
*/
private ensureIntermediatePaths(doc: any, patch: JsonPatchOp[]): void {
for (const op of patch) {
if (op.op !== 'add' && op.op !== 'replace') continue;
const parts = op.path.split('/').filter(Boolean);
if (parts.length <= 1) continue;
let current = doc;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current[part] === undefined || current[part] === null) {
current[part] = {};
}
current = current[part];
}
}
}
/**
* Apply a patch to state (used when applying local changes).
*/
applyPatch(patch: JsonPatchOp[]): void {
const result = applyPatch(deepClone(this.state), patch, false, false);
const doc = deepClone(this.state) as any;
this.ensureIntermediatePaths(doc, patch);
const result = applyPatch(doc, patch, false, false);
this.state = result.newDocument;
}
@@ -130,7 +152,8 @@ class SyncEntry {
try {
if (clientBaseSeq === this.seq) {
// No conflict - apply directly
const currentState = deepClone(this.state);
const currentState = deepClone(this.state) as any;
this.ensureIntermediatePaths(currentState, patch);
const result = applyPatch(currentState, patch, false, false);
this.state = result.newDocument;
this.commitPatch(patch);
@@ -160,14 +183,15 @@ class SyncEntry {
const frontendPaths = new Set(patch.map((op) => op.path));
// Apply frontend patch first
const currentState = deepClone(this.state);
const currentState = deepClone(this.state) as any;
this.ensureIntermediatePaths(currentState, patch);
let newState: unknown;
try {
const result = applyPatch(currentState, patch, false, false);
newState = result.newDocument;
} catch (e) {
logger?.warn(
{ store: this.storeName, error: e },
{ store: this.storeName, err: e },
'Failed to apply client patch during conflict resolution'
);
return { needsSnapshot: true, resolvedState: this.state };
@@ -182,7 +206,7 @@ class SyncEntry {
newState = result.newDocument;
} catch (e) {
logger?.debug(
{ store: this.storeName, error: e },
{ store: this.storeName, err: e },
'Skipping backend patch during conflict resolution'
);
}
@@ -209,7 +233,7 @@ class SyncEntry {
return { needsSnapshot: true, resolvedState: this.state };
} catch (e) {
logger?.error(
{ store: this.storeName, error: e },
{ store: this.storeName, err: e },
'Unexpected error applying client patch'
);
return { needsSnapshot: true, resolvedState: this.state };

View File

@@ -237,7 +237,7 @@ export interface ChartState {
symbol: string;
start_time: number | null; // unix timestamp
end_time: number | null; // unix timestamp
period: string; // OHLC duration (e.g., '15' for 15 minutes)
period: number; // OHLC period in seconds (e.g., 900 for 15 minutes)
selected_shapes: string[]; // list of shape ID's
}