feat: add database schema, migrations, and API endpoints for span annotations
- Add span_label_types and span_annotations tables to schema - Seed default span label types (bull_flag, bear_flag, etc.) - Implement CRUD API endpoints for span label types - Implement CRUD API endpoints for span annotations - Add time swap validation in POST endpoint (start_time <= end_time)
This commit is contained in:
parent
8a7eb1fb08
commit
dadf515406
11 changed files with 1131 additions and 0 deletions
27
drizzle/0003_demonic_captain_flint.sql
Normal file
27
drizzle/0003_demonic_captain_flint.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE `span_annotations` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`chart_id` integer NOT NULL,
|
||||
`start_time` integer NOT NULL,
|
||||
`end_time` integer NOT NULL,
|
||||
`label` text NOT NULL,
|
||||
`confidence` integer,
|
||||
`outcome` text,
|
||||
`notes` text,
|
||||
`sub_spans` text,
|
||||
`color` text DEFAULT '#2196F3' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`chart_id`) REFERENCES `charts`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `span_label_types` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`display_name` text NOT NULL,
|
||||
`color` text NOT NULL,
|
||||
`hotkey` text,
|
||||
`is_active` integer DEFAULT 1 NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `span_label_types_name_unique` ON `span_label_types` (`name`);
|
||||
466
drizzle/meta/0003_snapshot.json
Normal file
466
drizzle/meta/0003_snapshot.json
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "17f9ee96-d37f-4c25-97d8-45f8f1a4c868",
|
||||
"prevId": "8eb771d5-6d44-473e-8bce-144d195ae2b5",
|
||||
"tables": {
|
||||
"annotation_types": {
|
||||
"name": "annotation_types",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_types_name_unique": {
|
||||
"name": "annotation_types_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"annotations": {
|
||||
"name": "annotations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"chart_id": {
|
||||
"name": "chart_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"label_type": {
|
||||
"name": "label_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"geometry": {
|
||||
"name": "geometry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'#3b82f6'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"annotations_chart_id_charts_id_fk": {
|
||||
"name": "annotations_chart_id_charts_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "charts",
|
||||
"columnsFrom": [
|
||||
"chart_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"candles": {
|
||||
"name": "candles",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"chart_id": {
|
||||
"name": "chart_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"time": {
|
||||
"name": "time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"open": {
|
||||
"name": "open",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"high": {
|
||||
"name": "high",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"low": {
|
||||
"name": "low",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"close": {
|
||||
"name": "close",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"candles_chart_time_unique": {
|
||||
"name": "candles_chart_time_unique",
|
||||
"columns": [
|
||||
"chart_id",
|
||||
"time"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"candles_chart_id_charts_id_fk": {
|
||||
"name": "candles_chart_id_charts_id_fk",
|
||||
"tableFrom": "candles",
|
||||
"tableTo": "charts",
|
||||
"columnsFrom": [
|
||||
"chart_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"charts": {
|
||||
"name": "charts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"charts_name_unique": {
|
||||
"name": "charts_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"span_annotations": {
|
||||
"name": "span_annotations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"chart_id": {
|
||||
"name": "chart_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"confidence": {
|
||||
"name": "confidence",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"outcome": {
|
||||
"name": "outcome",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sub_spans": {
|
||||
"name": "sub_spans",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'#2196F3'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"span_annotations_chart_id_charts_id_fk": {
|
||||
"name": "span_annotations_chart_id_charts_id_fk",
|
||||
"tableFrom": "span_annotations",
|
||||
"tableTo": "charts",
|
||||
"columnsFrom": [
|
||||
"chart_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"span_label_types": {
|
||||
"name": "span_label_types",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"hotkey": {
|
||||
"name": "hotkey",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"span_label_types_name_unique": {
|
||||
"name": "span_label_types_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1770937855462,
|
||||
"tag": "0002_careful_synch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1771044740273,
|
||||
"tag": "0003_demonic_captain_flint",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
11
scripts/seed-span-labels.ts
Normal file
11
scripts/seed-span-labels.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { seedSpanLabelTypes } from '../src/lib/db/seed-span-label-types';
|
||||
|
||||
seedSpanLabelTypes()
|
||||
.then(() => {
|
||||
console.log('Seed complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
163
span-annotation-prompt.md
Normal file
163
span-annotation-prompt.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Span Annotation Feature for Candlestick Pattern Labeling Tool
|
||||
|
||||
## What is Span Annotation?
|
||||
|
||||
Span annotation means selecting a **range of consecutive candles** on a candlestick chart that together form a recognizable pattern (e.g., bull flag, head and shoulders, double bottom). The user clicks a start candle and an end candle, assigns a pattern label, and optionally adds metadata. This is the standard approach for labeling multi-candle patterns in time series data.
|
||||
|
||||
## User Interaction Flow
|
||||
|
||||
1. User enters **annotation mode** (toggle or hotkey)
|
||||
2. User **clicks a candle** → that candle is highlighted as the **span start**
|
||||
3. User **clicks a second candle** → that becomes the **span end**
|
||||
4. A **label selector** appears (dropdown or palette) with the user's predefined pattern categories
|
||||
5. Optionally, user can add:
|
||||
- **Sub-spans** (e.g., mark the "pole" and "flag" portions within a bull flag)
|
||||
- **Outcome** (win/loss/breakeven, or the price move after the pattern)
|
||||
- **Confidence** (how clear the pattern is, 1-5 scale)
|
||||
- **Free-text notes**
|
||||
6. The annotation is saved and **visually rendered** on the chart as a highlighted region with a label tag
|
||||
7. User can **click an existing annotation** to edit or delete it
|
||||
8. Annotations persist and are exportable
|
||||
|
||||
## Visual Rendering of Annotations
|
||||
|
||||
- Draw a **semi-transparent colored rectangle** behind the candles in the span range (color per label category)
|
||||
- Show the **label name** as a small tag above or below the highlighted region
|
||||
- Sub-spans get a **slightly different shade** or a thin divider line within the main span
|
||||
- Overlapping annotations should be visually distinguishable (offset vertically or use border styles)
|
||||
|
||||
## Annotation Data Model
|
||||
|
||||
Each annotation is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-v4",
|
||||
"pair": "EURUSD",
|
||||
"timeframe": "1H",
|
||||
"start_time": "2024-03-15T09:00:00Z",
|
||||
"end_time": "2024-03-15T16:00:00Z",
|
||||
"start_index": 142,
|
||||
"end_index": 149,
|
||||
"label": "bull_flag",
|
||||
"sub_spans": [
|
||||
{
|
||||
"label": "pole",
|
||||
"start_time": "2024-03-15T09:00:00Z",
|
||||
"end_time": "2024-03-15T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"label": "consolidation",
|
||||
"start_time": "2024-03-15T12:00:00Z",
|
||||
"end_time": "2024-03-15T16:00:00Z"
|
||||
}
|
||||
],
|
||||
"outcome": "win",
|
||||
"confidence": 4,
|
||||
"notes": "clean breakout on volume",
|
||||
"created_at": "2024-03-16T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Export Formats for ML Training
|
||||
|
||||
The tool must export annotations in multiple formats to support different model types. All exports should be triggered from a single "Export" button with format selection.
|
||||
|
||||
---
|
||||
|
||||
### Format 1: Windowed Classification (CSV)
|
||||
|
||||
One row per annotation. Used for training classifiers (XGBoost, CNN, LSTM) where each row is a labeled window of OHLC data.
|
||||
|
||||
```csv
|
||||
pair,timeframe,start_time,end_time,label,outcome,confidence,window_length,open_0,high_0,low_0,close_0,volume_0,open_1,high_1,low_1,close_1,volume_1,...
|
||||
EURUSD,1H,2024-03-15T09:00:00Z,2024-03-15T16:00:00Z,bull_flag,win,4,8,1.0921,1.0935,1.0918,1.0933,1200,1.0933,1.0948,1.0930,1.0945,1500,...
|
||||
```
|
||||
|
||||
The OHLCV columns are **flattened**: `open_0` through `close_N` where N is the number of candles in the span. Pad shorter spans with NaN or truncate/resample to a fixed window size (user-configurable, e.g., 20 candles).
|
||||
|
||||
---
|
||||
|
||||
### Format 2: Sequence Labels / BIO Tags (CSV)
|
||||
|
||||
One row per candle across the entire dataset. Used for sequence labeling models (BiLSTM-CRF, Transformer encoder). Uses BIO tagging scheme:
|
||||
- **B-{label}** = first candle of a pattern
|
||||
- **I-{label}** = inside a pattern (continuation)
|
||||
- **O** = outside any pattern (no pattern)
|
||||
|
||||
```csv
|
||||
time,open,high,low,close,volume,bio_tag
|
||||
2024-03-15T08:00:00Z,1.0915,1.0922,1.0910,1.0918,980,O
|
||||
2024-03-15T09:00:00Z,1.0921,1.0935,1.0918,1.0933,1200,B-bull_flag
|
||||
2024-03-15T10:00:00Z,1.0933,1.0948,1.0930,1.0945,1500,I-bull_flag
|
||||
2024-03-15T11:00:00Z,1.0944,1.0950,1.0938,1.0941,1100,I-bull_flag
|
||||
...
|
||||
2024-03-15T16:00:00Z,1.0939,1.0960,1.0937,1.0958,1800,I-bull_flag
|
||||
2024-03-15T17:00:00Z,1.0958,1.0965,1.0950,1.0962,900,O
|
||||
```
|
||||
|
||||
For overlapping annotations, use multi-label columns: `bio_tag_1`, `bio_tag_2`, etc.
|
||||
|
||||
---
|
||||
|
||||
### Format 3: Raw Annotations JSON
|
||||
|
||||
The complete annotation list as-is, for custom pipelines or re-import.
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"pair": "EURUSD",
|
||||
"timeframe": "1H",
|
||||
"export_date": "2024-03-20T12:00:00Z",
|
||||
"total_annotations": 47,
|
||||
"label_counts": {
|
||||
"bull_flag": 12,
|
||||
"head_and_shoulders": 8,
|
||||
"double_bottom": 15,
|
||||
"wedge": 12
|
||||
}
|
||||
},
|
||||
"annotations": [
|
||||
{ ... annotation objects as defined above ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
Format 2 (BIO tags) is probably the most versatile starting point — it works directly with sequence models and you can always derive Format 1 (windowed) from it by slicing. Format 1 (windowed CSV) is what you'd feed directly into XGBoost or a CNN. If you start with just one export format, go with the raw JSON (Format 3) since you can always transform it into the others with a script.
|
||||
|
||||
Make sure the export includes context candles — e.g., 10-20 candles before and after each pattern span. Models need to see the trend leading into the pattern, not just the pattern itself. You might want a configurable context_padding parameter on export.
|
||||
|
||||
|
||||
## Label Configuration
|
||||
|
||||
The user should be able to define their own pattern categories in a config, e.g.:
|
||||
|
||||
```json
|
||||
{
|
||||
"labels": [
|
||||
{ "name": "bull_flag", "color": "#4CAF50", "hotkey": "1" },
|
||||
{ "name": "bear_flag", "color": "#F44336", "hotkey": "2" },
|
||||
{ "name": "head_and_shoulders", "color": "#FF9800", "hotkey": "3" },
|
||||
{ "name": "double_bottom", "color": "#2196F3", "hotkey": "4" },
|
||||
{ "name": "wedge_up", "color": "#9C27B0", "hotkey": "5" },
|
||||
{ "name": "wedge_down", "color": "#795548", "hotkey": "6" },
|
||||
{ "name": "custom", "color": "#607D8B", "hotkey": "0" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Summary of Requirements
|
||||
|
||||
- Click-to-select span annotation on a TradingView Lightweight Charts candlestick chart
|
||||
- Label assignment via dropdown or hotkey
|
||||
- Optional sub-spans, outcome, confidence, notes
|
||||
- Visual overlay of annotations on the chart
|
||||
- Edit/delete existing annotations
|
||||
- Export to: Windowed CSV, BIO-tagged CSV, Raw JSON, and optionally image crops
|
||||
- User-configurable label categories with colors and hotkeys
|
||||
87
src/app/api/span-annotations/[id]/route.ts
Normal file
87
src/app/api/span-annotations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { spanAnnotations } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// PATCH - Update span annotation
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const { label, confidence, outcome, notes, sub_spans } = body;
|
||||
|
||||
// Check if the span exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(spanAnnotations)
|
||||
.where(eq(spanAnnotations.id, parseInt(id)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Span annotation not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updates: any = {};
|
||||
if (label !== undefined) updates.label = label;
|
||||
if (confidence !== undefined) updates.confidence = confidence;
|
||||
if (outcome !== undefined) updates.outcome = outcome;
|
||||
if (notes !== undefined) updates.notes = notes;
|
||||
if (sub_spans !== undefined) updates.sub_spans = sub_spans ? JSON.stringify(sub_spans) : null;
|
||||
|
||||
const result = await db
|
||||
.update(spanAnnotations)
|
||||
.set(updates)
|
||||
.where(eq(spanAnnotations.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(result[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Error updating span annotation:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update span annotation' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete span annotation
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Check if the span exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(spanAnnotations)
|
||||
.where(eq(spanAnnotations.id, parseInt(id)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Span annotation not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(spanAnnotations).where(eq(spanAnnotations.id, parseInt(id)));
|
||||
|
||||
return NextResponse.json({ message: 'Span annotation deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting span annotation:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete span annotation' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
86
src/app/api/span-annotations/route.ts
Normal file
86
src/app/api/span-annotations/route.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { spanAnnotations } from '@/lib/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
// GET - List all span annotations for a chart
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const chartId = searchParams.get('chartId');
|
||||
|
||||
if (!chartId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'chartId parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const spans = await db
|
||||
.select()
|
||||
.from(spanAnnotations)
|
||||
.where(eq(spanAnnotations.chart_id, parseInt(chartId)))
|
||||
.orderBy(desc(spanAnnotations.start_time));
|
||||
|
||||
return NextResponse.json(spans);
|
||||
} catch (error) {
|
||||
console.error('Error fetching span annotations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch span annotations' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create new span annotation
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
chart_id,
|
||||
start_time,
|
||||
end_time,
|
||||
label,
|
||||
confidence,
|
||||
outcome,
|
||||
notes,
|
||||
sub_spans,
|
||||
color,
|
||||
} = body;
|
||||
|
||||
if (!chart_id || start_time === undefined || end_time === undefined || !label) {
|
||||
return NextResponse.json(
|
||||
{ error: 'chart_id, start_time, end_time, and label are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure start_time <= end_time (swap if needed)
|
||||
const actualStartTime = Math.min(start_time, end_time);
|
||||
const actualEndTime = Math.max(start_time, end_time);
|
||||
|
||||
const result = await db
|
||||
.insert(spanAnnotations)
|
||||
.values({
|
||||
chart_id,
|
||||
start_time: actualStartTime,
|
||||
end_time: actualEndTime,
|
||||
label,
|
||||
confidence: confidence || null,
|
||||
outcome: outcome || null,
|
||||
notes: notes || null,
|
||||
sub_spans: sub_spans ? JSON.stringify(sub_spans) : null,
|
||||
color: color || '#2196F3',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(result[0], { status: 201 });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating span annotation:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create span annotation' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
src/app/api/span-label-types/[id]/route.ts
Normal file
110
src/app/api/span-label-types/[id]/route.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { spanLabelTypes, spanAnnotations } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// PATCH - Update span label type
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const { name, display_name, color, hotkey, is_active, sort_order } = body;
|
||||
|
||||
// Check if the type exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(spanLabelTypes)
|
||||
.where(eq(spanLabelTypes.id, parseInt(id)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Span label type not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updates: any = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (display_name !== undefined) updates.display_name = display_name;
|
||||
if (color !== undefined) updates.color = color;
|
||||
if (hotkey !== undefined) updates.hotkey = hotkey;
|
||||
if (is_active !== undefined) updates.is_active = is_active;
|
||||
if (sort_order !== undefined) updates.sort_order = sort_order;
|
||||
|
||||
const result = await db
|
||||
.update(spanLabelTypes)
|
||||
.set(updates)
|
||||
.where(eq(spanLabelTypes.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(result[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Error updating span label type:', error);
|
||||
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Span label type with this name already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update span label type' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete span label type (only if no span annotations use it)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Check if the type exists
|
||||
const type = await db
|
||||
.select()
|
||||
.from(spanLabelTypes)
|
||||
.where(eq(spanLabelTypes.id, parseInt(id)))
|
||||
.limit(1);
|
||||
|
||||
if (type.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Span label type not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any span annotations use this label
|
||||
const existingSpans = await db
|
||||
.select()
|
||||
.from(spanAnnotations)
|
||||
.where(eq(spanAnnotations.label, type[0].name))
|
||||
.limit(1);
|
||||
|
||||
if (existingSpans.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete span label type: span annotations exist with this label' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(spanLabelTypes).where(eq(spanLabelTypes.id, parseInt(id)));
|
||||
|
||||
return NextResponse.json({ message: 'Span label type deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting span label type:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete span label type' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/app/api/span-label-types/route.ts
Normal file
67
src/app/api/span-label-types/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { spanLabelTypes } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// GET - List all span label types (active only, sorted by sort_order)
|
||||
export async function GET() {
|
||||
try {
|
||||
const types = await db
|
||||
.select()
|
||||
.from(spanLabelTypes)
|
||||
.where(eq(spanLabelTypes.is_active, 1))
|
||||
.orderBy(spanLabelTypes.sort_order);
|
||||
|
||||
return NextResponse.json(types);
|
||||
} catch (error) {
|
||||
console.error('Error fetching span label types:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch span label types' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create new span label type
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, display_name, color, hotkey, sort_order } = body;
|
||||
|
||||
if (!name || !display_name || !color) {
|
||||
return NextResponse.json(
|
||||
{ error: 'name, display_name, and color are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(spanLabelTypes)
|
||||
.values({
|
||||
name,
|
||||
display_name,
|
||||
color,
|
||||
hotkey: hotkey || null,
|
||||
is_active: 1,
|
||||
sort_order: sort_order ?? 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(result[0], { status: 201 });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating span label type:', error);
|
||||
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Span label type with this name already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create span label type' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,3 +38,28 @@ export const annotations = sqliteTable('annotations', {
|
|||
color: text('color').default('#3b82f6'), // hex color code
|
||||
created_at: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const spanLabelTypes = sqliteTable('span_label_types', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull().unique(), // internal name (e.g., 'bull_flag')
|
||||
display_name: text('display_name').notNull(), // UI label (e.g., 'Bull Flag')
|
||||
color: text('color').notNull(), // hex color for rectangle fill
|
||||
hotkey: text('hotkey'), // keyboard shortcut (e.g., '1')
|
||||
is_active: integer('is_active').notNull().default(1), // 1 = active, 0 = inactive
|
||||
sort_order: integer('sort_order').notNull().default(0), // display order
|
||||
created_at: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const spanAnnotations = sqliteTable('span_annotations', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
chart_id: integer('chart_id').notNull().references(() => charts.id),
|
||||
start_time: integer('start_time').notNull(), // Unix timestamp of first candle
|
||||
end_time: integer('end_time').notNull(), // Unix timestamp of last candle
|
||||
label: text('label').notNull(), // pattern name referencing span_label_types.name
|
||||
confidence: integer('confidence'), // 1-5 scale, nullable
|
||||
outcome: text('outcome'), // 'win'|'loss'|'breakeven'|null
|
||||
notes: text('notes'), // free-text, nullable
|
||||
sub_spans: text('sub_spans'), // JSON array of sub-span objects, nullable
|
||||
color: text('color').notNull().default('#2196F3'), // hex color
|
||||
created_at: integer('created_at').notNull(),
|
||||
});
|
||||
|
|
|
|||
82
src/lib/db/seed-span-label-types.ts
Normal file
82
src/lib/db/seed-span-label-types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { db } from './index';
|
||||
import { spanLabelTypes } from './schema';
|
||||
|
||||
export async function seedSpanLabelTypes() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const defaultTypes = [
|
||||
{
|
||||
name: 'bull_flag',
|
||||
display_name: 'Bull Flag',
|
||||
color: '#4CAF50',
|
||||
hotkey: '1',
|
||||
is_active: 1,
|
||||
sort_order: 1,
|
||||
created_at: now,
|
||||
},
|
||||
{
|
||||
name: 'bear_flag',
|
||||
display_name: 'Bear Flag',
|
||||
color: '#F44336',
|
||||
hotkey: '2',
|
||||
is_active: 1,
|
||||
sort_order: 2,
|
||||
created_at: now,
|
||||
},
|
||||
{
|
||||
name: 'head_and_shoulders',
|
||||
display_name: 'Head and Shoulders',
|
||||
color: '#9C27B0',
|
||||
hotkey: '3',
|
||||
is_active: 1,
|
||||
sort_order: 3,
|
||||
created_at: now,
|
||||
},
|
||||
{
|
||||
name: 'double_bottom',
|
||||
display_name: 'Double Bottom',
|
||||
color: '#2196F3',
|
||||
hotkey: '4',
|
||||
is_active: 1,
|
||||
sort_order: 4,
|
||||
created_at: now,
|
||||
},
|
||||
{
|
||||
name: 'wedge_up',
|
||||
display_name: 'Wedge Up',
|
||||
color: '#FF9800',
|
||||
hotkey: '5',
|
||||
is_active: 1,
|
||||
sort_order: 5,
|
||||
created_at: now,
|
||||
},
|
||||
{
|
||||
name: 'wedge_down',
|
||||
display_name: 'Wedge Down',
|
||||
color: '#FF5722',
|
||||
hotkey: '6',
|
||||
is_active: 1,
|
||||
sort_order: 6,
|
||||
created_at: now,
|
||||
},
|
||||
{
|
||||
name: 'custom',
|
||||
display_name: 'Custom',
|
||||
color: '#607D8B',
|
||||
hotkey: '7',
|
||||
is_active: 1,
|
||||
sort_order: 7,
|
||||
created_at: now,
|
||||
},
|
||||
];
|
||||
|
||||
// Check if types already exist
|
||||
const existing = await db.select().from(spanLabelTypes);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(spanLabelTypes).values(defaultTypes);
|
||||
console.log('Seeded default span label types');
|
||||
} else {
|
||||
console.log('Span label types already exist, skipping seed');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue