diff --git a/drizzle/0003_demonic_captain_flint.sql b/drizzle/0003_demonic_captain_flint.sql new file mode 100644 index 0000000..e62a1a8 --- /dev/null +++ b/drizzle/0003_demonic_captain_flint.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..fffe38a --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f49aef9..d00b8e9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/scripts/seed-span-labels.ts b/scripts/seed-span-labels.ts new file mode 100644 index 0000000..7512e30 --- /dev/null +++ b/scripts/seed-span-labels.ts @@ -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); + }); diff --git a/span-annotation-prompt.md b/span-annotation-prompt.md new file mode 100644 index 0000000..c970f91 --- /dev/null +++ b/span-annotation-prompt.md @@ -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 diff --git a/src/app/api/span-annotations/[id]/route.ts b/src/app/api/span-annotations/[id]/route.ts new file mode 100644 index 0000000..e67dae9 --- /dev/null +++ b/src/app/api/span-annotations/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/span-annotations/route.ts b/src/app/api/span-annotations/route.ts new file mode 100644 index 0000000..dac3602 --- /dev/null +++ b/src/app/api/span-annotations/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/span-label-types/[id]/route.ts b/src/app/api/span-label-types/[id]/route.ts new file mode 100644 index 0000000..0f65e8f --- /dev/null +++ b/src/app/api/span-label-types/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/span-label-types/route.ts b/src/app/api/span-label-types/route.ts new file mode 100644 index 0000000..b603be3 --- /dev/null +++ b/src/app/api/span-label-types/route.ts @@ -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 } + ); + } +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 82141df..c69edbb 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(), +}); diff --git a/src/lib/db/seed-span-label-types.ts b/src/lib/db/seed-span-label-types.ts new file mode 100644 index 0000000..a8ed53c --- /dev/null +++ b/src/lib/db/seed-span-label-types.ts @@ -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'); + } +}