feat: add FastAPI model/load endpoint and all Next.js proxy routes (tasks 2-4)

This commit is contained in:
Marko Djordjevic 2026-02-17 18:47:04 +01:00
parent b8e649e333
commit 2a02669222
29 changed files with 1110 additions and 780 deletions

View file

@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-02-15

View file

@ -1,92 +0,0 @@
## Context
The candle annotator uses lightweight-charts for rendering candlestick data and currently has two rendering layers for annotations:
1. **Canvas-based (ISeriesPrimitive)**: Used by `SpanRectanglePrimitive.ts` for span annotations. These are attached to the series via `series.attachPrimitive()` and render natively within the chart canvas. They support `hitTest()`, autoscaling, and z-ordering.
2. **SVG overlay**: Used by `SvgOverlay.tsx` for line annotations. An absolutely-positioned SVG element sits on top of the chart with `zIndex: 1111`, intercepting pointer events when the line or delete tool is active. It duplicates coordinate conversion logic and manages its own interaction state.
The existing `src/plugins/trend-line.ts` already implements a `TrendLine` class using `ISeriesPrimitive<Time>` but is not wired into the interactive drawing flow. The Toolbox component manages tool modes — line drawing is triggered by annotation types with `category: 'line'`.
## Goals / Non-Goals
**Goals:**
- Replace SVG overlay line rendering with the existing `TrendLine` plugin (canvas-based)
- Add interactive drawing for lines using chart's native click/crosshair events instead of SVG pointer events
- Add a rectangle annotation tool using a new `RectangleDrawingPrimitive` plugin
- Maintain all existing line behaviors: two-click drawing, preview, selection, endpoint dragging, deletion
- Use the same database schema and API endpoints — rectangles stored as `label_type: "rectangle"` with geometry JSON
- Keep rendering approach consistent with `SpanRectanglePrimitive` patterns
**Non-Goals:**
- Changing span annotation implementation (already uses ISeriesPrimitive)
- Adding axis labels or price/time markers to lines or rectangles
- Multi-select or group operations on annotations
- Undo/redo functionality
- Toolbar UI for the rectangle drawing tool from the upstream example (we use our own Toolbox)
## Decisions
### 1. Remove SVG overlay entirely
**Decision**: Delete `SvgOverlay.tsx` and move all line rendering to the `TrendLine` plugin.
**Rationale**: The SVG overlay creates a layering problem — it must intercept pointer events by sitting above the chart, which blocks chart interactions (zoom, pan, crosshair) when the line tool is active. The plugin system renders within the chart canvas and can coexist with native chart interactions. `SpanRectanglePrimitive` already demonstrates this approach works well.
**Alternative considered**: Keep SVG for interactive drawing (preview), use plugin only for saved lines. Rejected because it still requires maintaining two rendering systems and the coordinate conversion duplication.
### 2. Manage drawing interaction in CandleChart via chart events
**Decision**: Use `chart.subscribeClick()` and `chart.subscribeCrosshairMove()` in `CandleChart.tsx` to handle the two-click drawing flow for both lines and rectangles.
**Rationale**: The chart API provides native click and crosshair events that include time/price data coordinates directly, eliminating the need for manual pixel-to-data conversion. This matches how the upstream `RectangleDrawingTool` example works. `CandleChart.tsx` already owns the chart and series references.
**Alternative considered**: Create a separate `DrawingManager` component. Rejected for this scope — CandleChart already manages span primitives, so adding line/rectangle primitive management follows the existing pattern. Extraction can happen later if complexity warrants it.
### 3. Enhance TrendLine plugin with hit testing and selection
**Decision**: Add `hitTest()` and `setSelected()` methods to the existing `TrendLine` class, following the `SpanRectanglePrimitive` pattern.
**Rationale**: `hitTest()` enables the chart's built-in click handling to detect which primitive was clicked, eliminating the need for manual distance-to-line-segment calculation in the overlay. Selection state changes line appearance (thicker stroke, handles).
**Implementation**: Hit testing for lines uses perpendicular distance to the line segment (same algorithm currently in `SvgOverlay`, moved into the renderer's coordinate space).
### 4. Create RectangleDrawingPrimitive following SpanRectanglePrimitive patterns
**Decision**: Create a new `src/plugins/rectangle-drawing.ts` that implements `ISeriesPrimitive<Time>` directly (not extending a base class), storing two corner points `{time, price}` and rendering a filled rectangle.
**Rationale**: The upstream `RectangleDrawingTool` example is complex (multiple axis views, toolbar UI, PluginBase class) — most of that complexity is unnecessary since we have our own Toolbox and don't need axis highlighting. Our `SpanRectanglePrimitive` already demonstrates a simpler rectangle rendering approach. The new plugin adapts that pattern but uses two arbitrary corner points instead of candle-range-derived bounds.
**Differences from SpanRectanglePrimitive**: Span rectangles are defined by candle ranges (start_time/end_time → compute min_low/max_high from candle data). Rectangle annotations are freeform — two arbitrary {time, price} corners, no dependency on candle data.
### 5. Preview primitives for drawing feedback
**Decision**: Create temporary "preview" instances of `TrendLine` and `RectangleDrawingPrimitive` during the two-click drawing flow. Update endpoint on crosshair move. Remove and replace with permanent instance on second click.
**Rationale**: This is the same approach used in the upstream `RectangleDrawingTool` example (`PreviewRectangle` class). A preview primitive with dashed/semi-transparent styling gives visual feedback without requiring a separate rendering layer. Attach on first click, update on move, detach and replace on second click.
### 6. Endpoint dragging via primitive update
**Decision**: When a line is selected and the user drags a handle, call `trendLine.updatePoints(p1, p2)` and `requestUpdate()` to re-render at new coordinates. On mouse up, persist via PATCH API.
**Rationale**: The `TrendLine` class already has `updatePoints()`. Drag detection uses crosshair move events. This replaces the SVG-based drag handling that manipulated DOM elements directly.
**Handle rendering**: Draw small circles at endpoints when selected, as part of the `TrendLinePaneRenderer.draw()` method (no separate SVG elements needed).
### 7. Database and API — no changes
**Decision**: Rectangles use the existing `annotations` table with `label_type: "rectangle"` and `geometry: {"startTime", "startPrice", "endTime", "endPrice"}` — identical schema to lines.
**Rationale**: The geometry format is the same (two points). No migration needed. The API endpoints (`POST/PATCH/DELETE /api/annotations`) already handle arbitrary `label_type` and `geometry` values.
## Risks / Trade-offs
**[Risk] Line hit testing accuracy in canvas coordinates** → The distance-to-line-segment algorithm needs to work in bitmap coordinate space. Mitigation: Port the existing algorithm from SvgOverlay, test with various zoom levels. Use a tolerance of ~10 CSS pixels (scaled by pixel ratio).
**[Risk] Breaking existing saved line annotations** → Existing lines in the database use the same geometry format, so they should render identically with the plugin. Mitigation: No data migration needed; verify rendering parity before removing SVG overlay.
**[Risk] Pointer event handling conflicts** → Chart's native click events fire for both annotation tools and regular chart interaction. Mitigation: Only handle annotation clicks when `activeTool` is set to a drawing/delete tool. When no tool is active, clicks pass through to normal chart behavior (zoom, crosshair, etc.).
**[Risk] Performance with many primitives** → Each line/rectangle is a separate `ISeriesPrimitive` instance. Mitigation: This is the same approach used for spans, and the number of annotations per chart is typically small (tens, not thousands).
**[Trade-off] No PluginBase class** → We implement `ISeriesPrimitive` directly rather than using the upstream `PluginBase` abstraction. This means slightly more boilerplate per plugin but avoids adding a dependency/abstraction for just two plugin types.

View file

@ -1,29 +0,0 @@
## Why
The current line drawing implementation uses an SVG overlay (`SvgOverlay.tsx`) positioned on top of the lightweight-charts canvas. This creates a disconnect — lines don't participate in chart autoscaling, coordinate conversion is duplicated outside the chart library, and the rendering layer is split between SVG and canvas. Lightweight-charts natively supports drawing primitives via the `ISeriesPrimitive` API, and the project already has a `TrendLine` plugin in `src/plugins/trend-line.ts` (unused) and uses `SpanRectanglePrimitive` for span annotations. Migrating line drawing to the native plugin system and adding rectangle annotations will unify the rendering approach, improve performance, and enable consistent interaction patterns.
## What Changes
- **BREAKING**: Remove SVG overlay line drawing (`SvgOverlay.tsx`) and replace with lightweight-charts `TrendLine` plugin-based rendering
- Implement interactive two-click line drawing using chart's native `subscribeClick()` and `subscribeCrosshairMove()` events instead of SVG pointer events
- Add a new rectangle annotation tool based on the lightweight-charts `RectangleDrawingTool` plugin pattern (two-click to define opposite corners)
- Add "rectangle" tool mode to the Toolbox alongside existing line/span/label tools
- Store rectangle annotations in the database with `label_type: "rectangle"` and geometry containing `{startTime, startPrice, endTime, endPrice}`
- Update line editing (drag endpoints) and deletion to work through the plugin system instead of SVG hit detection
- Add preview rendering during drawing for both lines and rectangles (using crosshair move events)
## Capabilities
### New Capabilities
- `rectangle-annotation`: Two-click rectangle drawing tool using lightweight-charts ISeriesPrimitive, with preview during drawing, persistence, editing, and deletion
### Modified Capabilities
- `annotation-tools`: Line drawing changes from SVG overlay to lightweight-charts TrendLine plugin; line tool mode interaction moves from SVG events to chart subscribeClick/subscribeCrosshairMove; line editing and deletion use plugin-based hit detection instead of SVG proximity calculation
## Impact
- **Frontend components**: `SvgOverlay.tsx` removed or gutted; `CandleChart.tsx` updated to manage TrendLine primitives; `Toolbox.tsx` gains rectangle tool button
- **Plugins**: `src/plugins/trend-line.ts` enhanced with interactive drawing support (click handlers, preview); new `src/plugins/rectangle-drawing.ts` added
- **API**: Existing annotation endpoints used as-is; rectangle annotations use the same `annotations` table with `label_type: "rectangle"` and `geometry` JSON
- **Database**: No schema changes — rectangles use the existing `geometry` column pattern (same as lines)
- **Dependencies**: No new dependencies — uses lightweight-charts APIs already in use

View file

@ -1,83 +0,0 @@
## ADDED Requirements
### Requirement: Active tool includes rectangle mode
The system SHALL add "rectangle" to the available tool modes alongside "select", "break_up", "break_down", "line", "span", and "delete". Only one tool SHALL be active at a time.
#### Scenario: Rectangle tool in tool list
- **WHEN** the Toolbox renders
- **THEN** a rectangle tool button is available alongside the existing tool buttons
### Requirement: Line rendering via TrendLine plugin
The system SHALL render saved line annotations using the `TrendLine` class (implementing `ISeriesPrimitive<Time>`) instead of SVG `<line>` elements. Each line annotation SHALL have one `TrendLine` primitive instance attached to the candlestick series via `series.attachPrimitive()`. The `SvgOverlay` component SHALL be removed.
#### Scenario: Saved lines render as canvas primitives
- **WHEN** line annotations exist for the active chart
- **THEN** each line renders via a TrendLine primitive on the chart canvas (not SVG overlay)
#### Scenario: Lines participate in autoscaling
- **WHEN** a line annotation's price range extends beyond visible candle data
- **THEN** the chart autoscale includes the line's price range via `autoscaleInfo()`
#### Scenario: Lines update on zoom/pan
- **WHEN** user zooms or pans the chart
- **THEN** line primitives automatically reposition via the ISeriesPrimitive lifecycle
### Requirement: Line hit testing
The `TrendLine` class SHALL implement `hitTest(x, y)` to detect clicks near the line. Hit testing SHALL calculate the perpendicular distance from the click point to the line segment and return a hit if within 10 CSS pixels (scaled by device pixel ratio).
#### Scenario: Click near line detected
- **WHEN** user clicks within 10 CSS pixels of a line segment
- **THEN** `hitTest()` returns a `PrimitiveHoveredItem` with the annotation ID as `externalId`
#### Scenario: Click far from line not detected
- **WHEN** user clicks more than 10 CSS pixels from any line segment
- **THEN** `hitTest()` returns null
### Requirement: Line selection handles via plugin
When a line is selected, the `TrendLine` renderer SHALL draw circular endpoint handles (radius 6px) at both endpoints of the line. The handles SHALL be rendered as part of the canvas draw call, not as separate SVG elements.
#### Scenario: Handles appear on selection
- **WHEN** a line is selected (via click with line tool active)
- **THEN** circular handles render at both endpoints of the line on the canvas
#### Scenario: Handles disappear on deselection
- **WHEN** the selected line is deselected (Escape key or clicking elsewhere)
- **THEN** the endpoint handles no longer render
## MODIFIED Requirements
### Requirement: Two-click line drawing
When the "line" tool is active, the system SHALL implement a two-click drawing interaction using `chart.subscribeClick()` for click detection and `chart.subscribeCrosshairMove()` for preview updates. The first click sets the start point (time, price). The second click sets the end point (time, price). After the second click, the system SHALL save an annotation with `label_type: "line"` and `geometry` containing JSON: `{"startTime": <unix>, "startPrice": <float>, "endTime": <unix>, "endPrice": <float>}`. The line SHALL render immediately as a TrendLine primitive attached to the candlestick series.
#### Scenario: Draw a trend line
- **WHEN** "line" tool is active and user clicks two points on the chart
- **THEN** system saves a line annotation with start/end coordinates and renders the line as a TrendLine primitive
#### Scenario: Visual feedback during line drawing
- **WHEN** "line" tool is active and user has clicked the first point but not the second
- **THEN** system displays a preview TrendLine primitive (dashed or semi-transparent) from the first point to the current crosshair position, updating via `subscribeCrosshairMove()`
#### Scenario: Cancel line drawing
- **WHEN** user presses Escape during a two-click line drawing (after first click)
- **THEN** system cancels the line drawing, detaches the preview primitive, and clears the drawing state without saving
### Requirement: Delete annotation
When the "delete" tool is active and the user clicks on or near an existing annotation (marker, line, or rectangle), the system SHALL remove that annotation from the database and update the chart display immediately. Line and rectangle hit detection SHALL use the primitive's `hitTest()` method instead of SVG proximity calculation.
#### Scenario: Delete a marker annotation
- **WHEN** "delete" tool is active and user clicks on a candle that has a marker annotation
- **THEN** system removes the annotation from the database and the marker disappears from the chart
#### Scenario: Delete a line annotation
- **WHEN** "delete" tool is active and user clicks near an existing line
- **THEN** system detects the hit via `TrendLine.hitTest()`, sends DELETE /api/annotations/{id}, detaches the primitive from the series, and updates the annotation list
#### Scenario: Delete a rectangle annotation
- **WHEN** "delete" tool is active and user clicks within a rectangle
- **THEN** system detects the hit via `RectangleDrawingPrimitive.hitTest()`, sends DELETE /api/annotations/{id}, detaches the primitive, and updates the annotation list
## REMOVED Requirements
### Requirement: SVG overlay rendering
**Reason**: Replaced by TrendLine plugin rendering. Line annotations now render via `ISeriesPrimitive` on the chart canvas instead of SVG `<line>` elements in an overlay. All coordinate conversion, hit detection, and interaction handling moves to the plugin system and chart native events.
**Migration**: Remove `SvgOverlay.tsx` component. Remove its usage from `CandleChart.tsx`. Line annotation data in the database is unchanged — the same geometry format is used by the TrendLine plugin.

View file

@ -1,105 +0,0 @@
## ADDED Requirements
### Requirement: Rectangle tool mode
The Toolbox SHALL include a "rectangle" tool button. When activated, the chart enters rectangle drawing mode. Only one tool SHALL be active at a time — activating the rectangle tool deactivates any other active tool.
#### Scenario: Activate rectangle tool
- **WHEN** user clicks the rectangle tool button in the Toolbox
- **THEN** the rectangle tool becomes active, the button appears visually selected, and the chart cursor changes to crosshair
#### Scenario: Deactivate rectangle tool
- **WHEN** user clicks the already-active rectangle tool button
- **THEN** the tool deactivates, the mode returns to "select", and the cursor returns to default
#### Scenario: Rectangle tool deactivates other tools
- **WHEN** the "line" tool is active and user clicks the rectangle tool button
- **THEN** the line tool deactivates and the rectangle tool becomes active
### Requirement: Two-click rectangle drawing
When the "rectangle" tool is active, the system SHALL implement a two-click interaction to define a rectangle. The first click sets one corner point (time, price). The second click sets the opposite corner point (time, price). The rectangle is defined by these two arbitrary data-coordinate corners — it is NOT constrained to candle boundaries.
#### Scenario: Draw a rectangle
- **WHEN** "rectangle" tool is active and user clicks two points on the chart
- **THEN** the system saves a rectangle annotation with the two corner coordinates and renders a filled semi-transparent rectangle on the chart
#### Scenario: First click registers corner
- **WHEN** "rectangle" tool is active and user clicks on the chart
- **THEN** the system records the click position as {time, price} for the first corner
#### Scenario: Second click completes rectangle
- **WHEN** user has set the first corner and clicks a second position
- **THEN** the system saves a rectangle annotation via POST /api/annotations with `label_type: "rectangle"` and `geometry: {"startTime", "startPrice", "endTime", "endPrice"}`
### Requirement: Rectangle preview during drawing
After the first click, the system SHALL display a preview rectangle that stretches from the first corner to the current cursor position. The preview SHALL have a dashed border and reduced opacity to distinguish it from saved rectangles.
#### Scenario: Preview follows cursor
- **WHEN** user has clicked the first corner and moves the mouse
- **THEN** a semi-transparent preview rectangle renders from the first corner to the cursor position, updating in real-time via crosshair move events
#### Scenario: Preview disappears on cancel
- **WHEN** user presses Escape during rectangle drawing (after first click)
- **THEN** the preview rectangle disappears and the drawing is cancelled without saving
### Requirement: Rectangle rendering via ISeriesPrimitive
The system SHALL render saved rectangle annotations using a `RectangleDrawingPrimitive` class that implements `ISeriesPrimitive<Time>`. Each rectangle annotation SHALL have one primitive instance attached to the candlestick series via `series.attachPrimitive()`.
#### Scenario: Rectangle renders for saved annotation
- **WHEN** a rectangle annotation exists for the active chart
- **THEN** a semi-transparent filled rectangle renders on the chart canvas at the stored corner coordinates
#### Scenario: Rectangle uses annotation color
- **WHEN** a rectangle annotation has a color value
- **THEN** the rectangle renders with that color as fill (at reduced opacity) and border
#### Scenario: Rectangle updates on zoom/pan
- **WHEN** user zooms or pans the chart
- **THEN** rectangle primitives automatically reposition via the ISeriesPrimitive lifecycle (coordinate conversion in paneView.update())
#### Scenario: Rectangle z-order
- **WHEN** rectangle annotations render on the chart
- **THEN** they SHALL render at "bottom" z-order (behind candlesticks), consistent with span rectangles
### Requirement: Rectangle hit testing
The `RectangleDrawingPrimitive` SHALL implement `hitTest(x, y)` to detect clicks within the rectangle bounds. Hit testing SHALL convert pixel coordinates to data coordinates and check if the point falls within the rectangle's time/price range.
#### Scenario: Click inside rectangle detected
- **WHEN** user clicks at a position inside a rectangle's bounds
- **THEN** `hitTest()` returns a `PrimitiveHoveredItem` with the annotation ID as `externalId`
#### Scenario: Click outside rectangle not detected
- **WHEN** user clicks at a position outside all rectangle bounds
- **THEN** `hitTest()` returns null
### Requirement: Rectangle selection
The system SHALL allow users to select a rectangle annotation by clicking within its bounds when the "rectangle" or "select" tool is active.
#### Scenario: Click to select rectangle
- **WHEN** user clicks within a rectangle annotation
- **THEN** the rectangle appears selected (thicker border or increased opacity)
#### Scenario: Click to deselect rectangle
- **WHEN** user clicks outside all rectangle annotations while one is selected
- **THEN** the selection is cleared
### Requirement: Rectangle deletion
The system SHALL allow users to delete rectangle annotations via the delete tool.
#### Scenario: Delete rectangle with delete tool
- **WHEN** the "delete" tool is active and user clicks within a rectangle
- **THEN** the system sends DELETE /api/annotations/{id}, removes the primitive from the chart, and updates the annotation list
#### Scenario: Delete selected rectangle with keyboard
- **WHEN** a rectangle is selected and user presses Delete or Backspace
- **THEN** the system deletes the rectangle annotation and removes it from the chart
### Requirement: Rectangle database storage
Rectangle annotations SHALL be stored in the existing `annotations` table with `label_type: "rectangle"` and `geometry` containing JSON: `{"startTime": <unix>, "startPrice": <float>, "endTime": <float>, "endPrice": <float>}`. The `startTime/startPrice` represents one corner, `endTime/endPrice` the opposite corner.
#### Scenario: Rectangle annotation persisted
- **WHEN** user completes a two-click rectangle drawing
- **THEN** the system sends POST /api/annotations with label_type "rectangle", the active chart_id, selected color, and geometry JSON with the two corner coordinates
#### Scenario: Rectangle annotation loaded on chart switch
- **WHEN** user switches to a chart that has rectangle annotations
- **THEN** the system fetches annotations, creates RectangleDrawingPrimitive instances for each rectangle annotation, and attaches them to the series

View file

@ -1,57 +0,0 @@
## 1. Enhance TrendLine Plugin
- [x] 1.1 Add `hitTest(x, y)` method to `TrendLine` class — calculate perpendicular distance from click point to line segment in pixel coordinates, return `PrimitiveHoveredItem` with annotation ID if within 10px tolerance
- [x] 1.2 Add `setSelected(isSelected)` method and selection state to `TrendLine` — when selected, renderer draws thicker line and circular endpoint handles (radius 6px, white fill, colored stroke)
- [x] 1.3 Add `attached()/detached()` lifecycle methods to `TrendLine` to store `SeriesAttachedParameter` reference (needed for hitTest coordinate conversion)
- [x] 1.4 Add preview line support — dashed stroke style and reduced opacity when an `isPreview` option is set
## 2. Create RectangleDrawingPrimitive Plugin
- [x] 2.1 Create `src/plugins/rectangle-drawing.ts` implementing `ISeriesPrimitive<Time>` with renderer, pane view, and data model for two corner points `{time, price}`
- [x] 2.2 Implement `RectangleDrawingPaneRenderer.draw()` — filled semi-transparent rectangle with border, using `useBitmapCoordinateSpace` for HiDPI scaling
- [x] 2.3 Implement `hitTest(x, y)` — convert pixel to data coordinates, check if within rectangle time/price bounds
- [x] 2.4 Add `setSelected(isSelected)` and selection visual feedback (thicker border, increased opacity)
- [x] 2.5 Add preview mode support — dashed border and reduced opacity for preview rectangles during drawing
- [x] 2.6 Add `autoscaleInfo()` to include rectangle price range in chart scaling
- [x] 2.7 Set z-order to "bottom" so rectangles render behind candlesticks
## 3. Wire Up Drawing Interaction in CandleChart
- [x] 3.1 Add state for drawing mode: `drawingState: {tool: 'line'|'rectangle', firstPoint: {time, price}} | null` and `previewPrimitive: TrendLine | RectangleDrawingPrimitive | null`
- [x] 3.2 Subscribe to `chart.subscribeClick()` — on first click when line/rectangle tool active, record first point and attach preview primitive; on second click, save annotation via API, detach preview, attach permanent primitive
- [x] 3.3 Subscribe to `chart.subscribeCrosshairMove()` — when drawing in progress, update preview primitive's endpoint via `updatePoints()` or equivalent
- [x] 3.4 Handle Escape key — detach preview primitive and clear drawing state
- [x] 3.5 Manage TrendLine primitives for saved line annotations — create/attach on load, detach on delete, update on edit
## 4. Wire Up Rectangle Primitives in CandleChart
- [x] 4.1 On annotation fetch, create `RectangleDrawingPrimitive` instances for `label_type: "rectangle"` annotations and attach to series
- [x] 4.2 On chart switch, detach old rectangle primitives and create new ones for the new chart's annotations
- [x] 4.3 Handle rectangle selection — on click hit detected via primitive `hitTest()`, call `setSelected()` and track selected annotation ID
- [x] 4.4 Handle rectangle deletion — when delete tool active and hit detected, send DELETE API call, detach primitive, refresh annotations
## 5. Update Toolbox
- [x] 5.1 Add "rectangle" tool button to Toolbox (using existing `RectangleHorizontal` lucide icon import, which is already present)
- [x] 5.2 Wire rectangle button to `onToolChange('rectangle')` with same toggle behavior as other tools
## 6. Remove SVG Overlay
- [x] 6.1 Remove `SvgOverlay` import and JSX from `CandleChart.tsx`
- [x] 6.2 Delete `src/components/SvgOverlay.tsx`
- [x] 6.3 Move line annotation primitive management into CandleChart (replace what SvgOverlay was doing — loading saved lines, managing line primitives on annotation fetch/delete)
## 7. Line Endpoint Dragging
- [x] 7.1 Implement drag detection — when a selected line's endpoint handle is clicked (via hitTest near endpoint), enter drag mode
- [x] 7.2 On crosshair move during drag, call `trendLine.updatePoints()` to reposition the dragged endpoint in real-time
- [x] 7.3 On click to release drag, persist updated geometry via PATCH /api/annotations/{id}
## 8. Verification
- [x] 8.1 Verify existing saved line annotations render correctly with TrendLine plugin (visual parity with old SVG rendering)
- [x] 8.2 Verify line drawing, preview, selection, dragging, and deletion all work end-to-end
- [x] 8.3 Verify rectangle drawing, preview, selection, and deletion work end-to-end
- [x] 8.4 Verify chart zoom/pan correctly repositions both line and rectangle primitives
- [x] 8.5 Verify tool switching between line, rectangle, span, label, and delete modes works correctly
- [x] 8.6 Verify annotations persist and reload correctly on chart switch

View file

@ -1,110 +0,0 @@
## Context
The candle annotator runs two databases:
1. **SQLite** (`data/candles.db`) — serves the Next.js frontend via Drizzle ORM (`better-sqlite3` driver). Contains 6 tables: charts, candles, annotations, annotation_types, span_annotations, span_label_types.
2. **PostgreSQL** (`postgres:5432/ml_db`) — serves the Python ML service via SQLAlchemy. Contains 1 table: training_runs.
The ML service cannot directly query annotation/candle data. Data flows through CSV/JSON file exports. PostgreSQL already runs in Docker for the ML service, so consolidating means adding frontend tables there — not introducing a new service.
## Goals / Non-Goals
**Goals:**
- Single PostgreSQL instance for all application data
- Drizzle ORM continues to manage frontend schema (just switches dialect)
- ML service gains direct read access to candle/annotation tables
- Simplified Docker setup (one fewer volume, one database to back up)
- One-time data migration path from SQLite to PostgreSQL
**Non-Goals:**
- Changing the ML service ORM (SQLAlchemy stays)
- Merging Drizzle and SQLAlchemy migration systems (each manages its own tables)
- Changing API route logic or query patterns beyond what's needed for the dialect switch
- Multi-tenant or schema separation (all tables go in the `public` schema)
- Migrating away from Drizzle ORM
## Decisions
### 1. Drizzle PostgreSQL driver: `drizzle-orm/node-postgres` with `pg`
**Choice**: Use `pg` (node-postgres) as the driver.
**Why**: `pg` is the most mature PostgreSQL driver for Node.js. Drizzle supports it natively via `drizzle-orm/node-postgres`. The `postgres` (postgres.js) driver is also an option but `pg` has broader ecosystem support and is easier to debug.
**Alternative considered**: `postgres` (postgres.js) — lighter, promise-native, but less battle-tested with Drizzle migrations.
### 2. Shared database, single `public` schema
**Choice**: All tables (frontend + ML) live in the same database (`ml_db`) and the default `public` schema.
**Why**: The table sets don't overlap (frontend has charts/candles/annotations, ML has training_runs). Separate schemas add complexity with no benefit for 7 total tables. The ML service already connects to `ml_db`.
**Alternative considered**: Separate PostgreSQL schemas (`app` and `ml`) — cleaner isolation but adds schema-prefix complexity to queries and cross-schema references. Not worth it at this scale.
### 3. Rename database from `ml_db` to `candle_annotator`
**Choice**: Rename the PostgreSQL database to `candle_annotator` since it now serves the whole application, not just ML.
**Why**: `ml_db` is misleading when the database holds frontend data too. Renaming during consolidation is the natural time to do it.
**Alternative considered**: Keep `ml_db` — avoids a rename step but creates lasting confusion.
### 4. Fresh Drizzle migrations (drop SQLite migrations)
**Choice**: Delete all existing SQLite migrations in `drizzle/`, rewrite the schema file with `pgTable` equivalents, and run `drizzle-kit generate` to produce a fresh initial PostgreSQL migration.
**Why**: SQLite migrations are dialect-specific (e.g., `integer` for booleans, no native timestamps). Converting them one-by-one is fragile. A clean start from the PostgreSQL schema is simpler and produces idiomatic SQL.
**Alternative considered**: Manually converting each SQLite migration to PostgreSQL — error-prone and provides no benefit since there's no production data that needs incremental migration history.
### 5. Type mappings: SQLite → PostgreSQL
| SQLite type | PostgreSQL type | Notes |
|---|---|---|
| `integer` (PK, autoIncrement) | `serial` | Auto-incrementing integer |
| `integer` (timestamps) | `timestamp` | Use `defaultNow()` where applicable |
| `integer` (booleans like `is_active`) | `boolean` | True PostgreSQL booleans |
| `real` | `doublePrecision` | OHLC price data |
| `text` | `text` | No change |
| `text` (JSON strings) | `jsonb` | For `geometry`, `sub_spans`, `model_prediction` |
### 6. Connection management for Next.js
**Choice**: Use a connection pool via `pg.Pool` with `max: 10` connections. Connection string from `DATABASE_URL` env var.
**Why**: SQLite was single-file, no pooling needed. PostgreSQL requires connection pooling for concurrent API requests. 10 connections is reasonable for the frontend workload.
### 7. ML service direct access to frontend tables
**Choice**: The ML service reads frontend tables (candles, annotations, span_annotations) directly via SQLAlchemy using its existing connection. No new SQLAlchemy models needed — raw SQL queries or lightweight table reflections are sufficient for read-only access.
**Why**: The ML service only needs to read training data. Adding full SQLAlchemy models for tables owned by Drizzle creates a dual-ownership problem. Raw queries or `Table` reflections keep it simple.
## Risks / Trade-offs
**[Schema drift between Drizzle and SQLAlchemy]** → Both ORMs manage tables in the same database. Drizzle owns frontend tables, SQLAlchemy owns ML tables. Neither should modify the other's tables. This is enforced by convention, not tooling.
**[Connection pool exhaustion]** → Adding the frontend's database traffic to the same PostgreSQL instance increases load. Mitigation: PostgreSQL 16 handles far more concurrent connections than SQLite. The `pg.Pool` max of 10 plus SQLAlchemy's pool of 5 is well within PostgreSQL's default `max_connections` of 100.
**[Data loss during migration]** → SQLite data must be migrated before switching. Mitigation: Write a migration script that exports SQLite data and imports to PostgreSQL. Run before deploying the new code. Keep the SQLite file as backup.
**[Drizzle push/generate differences]** → PostgreSQL dialect may generate slightly different migration SQL than expected. Mitigation: Review generated migrations before applying. Use `drizzle-kit push` for development, `drizzle-kit generate` + `drizzle-kit migrate` for production.
**[Boolean conversion]** → SQLite uses `0/1` for booleans, PostgreSQL uses `true/false`. Mitigation: The migration script handles conversion. Drizzle's `boolean()` type handles this transparently at the ORM level going forward.
## Migration Plan
1. **Update schema and dependencies** — Rewrite Drizzle schema for PostgreSQL, swap npm packages
2. **Generate fresh migrations**`drizzle-kit generate` from the new PostgreSQL schema
3. **Update docker-compose.yml** — Rename database, add frontend dependency on postgres, remove `candle-data` volume
4. **Update environment variables**`DATABASE_URL` for the frontend service
5. **Write data migration script**`scripts/migrate-sqlite-to-postgres.ts` that reads SQLite and inserts into PostgreSQL with type conversions
6. **Update db/index.ts** — Switch from `better-sqlite3` to `pg` pool, update migration runner
7. **Test locally** — Run migrations, migrate data, verify API routes work
8. **Deploy** — Stop current services, run PostgreSQL migrations, run data migration, deploy new code
9. **Rollback** — If issues arise, revert docker-compose and code, restore SQLite volume. The SQLite file is kept as backup for 1 week post-migration.
## Open Questions
- Should the ML service user (`ml_user`) have write access to frontend tables, or should we create a separate read-only role? (Recommendation: keep `ml_user` with full access for simplicity, revisit if the team grows.)
- Do we need to preserve SQLite migration history in git for reference, or delete the `drizzle/` folder contents entirely? (Recommendation: delete and start fresh.)

View file

@ -1,35 +0,0 @@
## Why
The project currently runs two separate database servers: SQLite (via Drizzle ORM) for the Next.js frontend and PostgreSQL for the ML service. This creates unnecessary operational complexity — two different ORMs, two migration systems, two backup strategies, and no ability for the ML service to directly query annotation/candle data. Consolidating to PostgreSQL as the single database simplifies deployment, enables direct cross-service data access, and reduces the infrastructure footprint.
## What Changes
- **BREAKING**: Replace SQLite/better-sqlite3/Drizzle with PostgreSQL/Drizzle (pg driver) for the Next.js frontend
- Remove the `candle-data` Docker volume (SQLite file storage) and `DATABASE_PATH` env var
- Migrate all frontend tables (charts, candles, annotations, annotation_types, span_annotations, span_label_types) into the existing PostgreSQL instance
- Update Drizzle schema and config to target PostgreSQL instead of SQLite
- Regenerate Drizzle migrations for PostgreSQL dialect (column types change: `integer``serial`, `real``double precision`, timestamps as proper `timestamp` types, etc.)
- Update the ML service to share the same PostgreSQL database (or a separate schema within it) so it can directly query candle/annotation data instead of relying on CSV/JSON exports
- Update docker-compose.yml to remove SQLite volume dependency and point the frontend at PostgreSQL
- Update environment variables: frontend gets `DATABASE_URL` pointing to PostgreSQL
## Capabilities
### New Capabilities
- `postgres-data-layer`: Unified PostgreSQL data access layer for the Next.js frontend, replacing the SQLite/better-sqlite3 setup with Drizzle's PostgreSQL driver
### Modified Capabilities
- `docker-deployment`: Container configuration changes — remove SQLite volume, add PostgreSQL dependency for the frontend service, update environment variables
- `ml-training`: ML service can now query annotations and candle data directly from PostgreSQL instead of requiring CSV/JSON file exports
## Impact
- **Database schema**: All 6 frontend tables move to PostgreSQL with type adaptations (SQLite integers → PostgreSQL serial/integer/timestamp)
- **ORM layer**: `src/lib/db/index.ts` switches from `better-sqlite3` to `postgres` driver; schema types in `src/lib/db/schema.ts` change to PostgreSQL equivalents
- **Dependencies**: Remove `better-sqlite3`, add `postgres` (or `pg`) npm package for Drizzle's PostgreSQL adapter
- **Migrations**: Existing SQLite migrations become obsolete; new PostgreSQL migrations needed
- **Docker**: `candle-annotator` service gains `depends_on: postgres`, loses `candle-data` volume mount
- **Environment**: `.env` and `.env.example` updated with PostgreSQL connection string for frontend
- **ML service**: `services/ml/app/db.py` gains access to frontend tables (candles, annotations) for direct querying
- **Data migration**: Existing SQLite data needs a one-time migration script to PostgreSQL
- **API routes**: All Next.js API routes using `db` from `src/lib/db` continue working (Drizzle abstracts the driver change), but queries using SQLite-specific syntax may need adjustment

View file

@ -1,81 +0,0 @@
## MODIFIED Requirements
### Requirement: Docker Compose configuration
The project SHALL include docker-compose.yml for simplified deployment orchestration.
#### Scenario: Service definition
- **WHEN** docker-compose.yml is parsed
- **THEN** defines service named 'candle-annotator' using Dockerfile from current directory
#### Scenario: Port mapping
- **WHEN** docker-compose up runs
- **THEN** maps host port 3000 to container port 3000
#### Scenario: Volume mounting for ML data
- **WHEN** docker-compose up runs
- **THEN** mounts named volume 'ml-data' to /app/ml-data in the candle-annotator container
#### Scenario: Frontend depends on PostgreSQL
- **WHEN** docker-compose up runs
- **THEN** the candle-annotator service starts only after the postgres service is healthy (`depends_on: postgres: condition: service_healthy`)
#### Scenario: Frontend DATABASE_URL
- **WHEN** the candle-annotator service starts
- **THEN** the `DATABASE_URL` environment variable is set to `postgresql://ml_user:ml_password@postgres:5432/candle_annotator`
#### Scenario: Restart policy
- **WHEN** container crashes or stops
- **THEN** docker-compose automatically restarts container unless explicitly stopped (restart: unless-stopped)
#### Scenario: No SQLite volume
- **WHEN** docker-compose.yml is parsed
- **THEN** there is no `candle-data` volume defined or mounted
### Requirement: Environment variable configuration
The project SHALL use environment variables for runtime configuration.
#### Scenario: .env.example file
- **WHEN** repository is cloned
- **THEN** includes .env.example file documenting all configurable environment variables with example values
#### Scenario: DATABASE_URL configuration
- **WHEN** `DATABASE_URL` environment variable is set
- **THEN** the Next.js application connects to the PostgreSQL database at the specified URL
#### Scenario: No DATABASE_PATH variable
- **WHEN** environment variables are inspected
- **THEN** there is no `DATABASE_PATH` variable (SQLite path is removed)
#### Scenario: PORT configuration
- **WHEN** PORT environment variable is set
- **THEN** Next.js server listens on specified port (default: 3000)
#### Scenario: NODE_ENV configuration
- **WHEN** NODE_ENV environment variable is set to 'production'
- **THEN** Next.js runs in production mode with optimizations enabled
### Requirement: Database persistence
The deployment SHALL ensure PostgreSQL data persists across container restarts.
#### Scenario: PostgreSQL volume
- **WHEN** docker-compose up runs
- **THEN** the `postgres-data` named volume is mounted to `/var/lib/postgresql/data` in the postgres container
#### Scenario: Container restart preserves data
- **WHEN** the postgres container is stopped and restarted
- **THEN** all database tables and data remain intact
#### Scenario: PostgreSQL database name
- **WHEN** the postgres service starts
- **THEN** the `POSTGRES_DB` environment variable is set to `candle_annotator`
### Requirement: Health check endpoint
The API SHALL provide a health check endpoint for container orchestration.
#### Scenario: Health check endpoint responds
- **WHEN** GET request sent to `/api/health`
- **THEN** system returns 200 status with JSON `{ status: 'ok', timestamp: <unix_timestamp> }`
#### Scenario: Database connection check
- **WHEN** GET request sent to `/api/health?check=db`
- **THEN** system attempts a PostgreSQL query and returns 200 if successful, 503 if database unavailable

View file

@ -1,37 +0,0 @@
## MODIFIED Requirements
### Requirement: PostgreSQL training metadata storage
The system SHALL store training run metadata in the PostgreSQL database. Each training run record SHALL include: run_id (MLflow run ID), model_type, experiment_name, pipeline_config_hash, dataset_version, metrics summary (JSON), status, and timestamps (created_at, completed_at).
#### Scenario: Store training run record
- **WHEN** a training run completes successfully
- **THEN** the system inserts a record into the PostgreSQL `training_runs` table with the run metadata
#### Scenario: Query training history
- **WHEN** the system queries training runs
- **THEN** it returns records from PostgreSQL ordered by created_at descending
#### Scenario: Database name updated
- **WHEN** the ML service connects to PostgreSQL
- **THEN** it connects to the `candle_annotator` database (not `ml_db`)
## ADDED Requirements
### Requirement: Direct annotation data access
The ML service SHALL read candle and annotation data directly from PostgreSQL instead of requiring CSV/JSON file exports. The ML service SHALL query the `candles`, `annotations`, `span_annotations`, and `charts` tables for training data.
#### Scenario: Query candle data for training
- **WHEN** the ML training pipeline needs OHLC data for a chart
- **THEN** it queries the `candles` table in PostgreSQL filtered by `chart_id`, ordered by `time`
#### Scenario: Query span annotations for labels
- **WHEN** the ML training pipeline needs labeled spans for training
- **THEN** it queries the `span_annotations` table in PostgreSQL filtered by `chart_id` and optionally by `source`
#### Scenario: No CSV/JSON export required
- **WHEN** the ML training pipeline starts
- **THEN** it does not require pre-exported CSV or JSON files — all data is read from PostgreSQL
#### Scenario: Shared database connection
- **WHEN** the ML service reads candle/annotation data
- **THEN** it uses the same PostgreSQL connection (same database, same credentials) as for `training_runs`

View file

@ -1,80 +0,0 @@
## ADDED Requirements
### Requirement: PostgreSQL connection via Drizzle ORM
The Next.js application SHALL connect to PostgreSQL using Drizzle ORM with the `node-postgres` (`pg`) driver. The connection SHALL use a pool with a configurable maximum number of connections (default: 10). The connection string SHALL be read from the `DATABASE_URL` environment variable.
#### Scenario: Successful connection
- **WHEN** the application starts with a valid `DATABASE_URL` pointing to a running PostgreSQL instance
- **THEN** Drizzle ORM establishes a connection pool and the `db` export is ready for queries
#### Scenario: Missing DATABASE_URL
- **WHEN** the `DATABASE_URL` environment variable is not set
- **THEN** the application SHALL fail to start with an error message indicating the missing variable
#### Scenario: Database unreachable
- **WHEN** the PostgreSQL instance is not reachable at the configured URL
- **THEN** the application SHALL fail to start with a connection error
### Requirement: PostgreSQL schema definitions
The Drizzle schema SHALL define all frontend tables using `pgTable` from `drizzle-orm/pg-core`. The following tables SHALL be defined: `charts`, `candles`, `annotation_types`, `annotations`, `span_label_types`, `span_annotations`.
#### Scenario: Charts table schema
- **WHEN** the schema is loaded
- **THEN** the `charts` table has columns: `id` (serial, primary key), `name` (text, unique, not null), `created_at` (timestamp, not null, default now)
#### Scenario: Candles table schema
- **WHEN** the schema is loaded
- **THEN** the `candles` table has columns: `id` (serial, primary key), `chart_id` (integer, foreign key to charts.id, not null), `time` (timestamp, not null), `open` (double precision, not null), `high` (double precision, not null), `low` (double precision, not null), `close` (double precision, not null), with a unique index on `(chart_id, time)`
#### Scenario: Annotation types table schema
- **WHEN** the schema is loaded
- **THEN** the `annotation_types` table has columns: `id` (serial, primary key), `name` (text, unique, not null), `display_name` (text, not null), `color` (text, not null), `category` (text, not null), `icon` (text, nullable), `is_active` (boolean, not null, default true), `created_at` (timestamp, not null, default now)
#### Scenario: Annotations table schema
- **WHEN** the schema is loaded
- **THEN** the `annotations` table has columns: `id` (serial, primary key), `chart_id` (integer, foreign key to charts.id, not null), `timestamp` (timestamp, not null), `label_type` (text, not null), `geometry` (jsonb, nullable), `color` (text, default '#3b82f6'), `created_at` (timestamp, not null, default now)
#### Scenario: Span label types table schema
- **WHEN** the schema is loaded
- **THEN** the `span_label_types` table has columns: `id` (serial, primary key), `name` (text, unique, not null), `display_name` (text, not null), `color` (text, not null), `hotkey` (text, nullable), `is_active` (boolean, not null, default true), `sort_order` (integer, not null, default 0), `created_at` (timestamp, not null, default now)
#### Scenario: Span annotations table schema
- **WHEN** the schema is loaded
- **THEN** the `span_annotations` table has columns: `id` (serial, primary key), `chart_id` (integer, foreign key to charts.id, not null), `start_time` (timestamp, not null), `end_time` (timestamp, not null), `label` (text, not null), `confidence` (integer, nullable), `outcome` (text, nullable), `notes` (text, nullable), `sub_spans` (jsonb, nullable), `color` (text, not null, default '#2196F3'), `source` (text, not null, default 'human'), `model_prediction` (jsonb, nullable), `created_at` (timestamp, not null, default now)
### Requirement: PostgreSQL migrations via Drizzle Kit
The project SHALL use Drizzle Kit to generate and apply PostgreSQL migrations. The `drizzle.config.ts` SHALL target the `postgresql` dialect. Existing SQLite migrations SHALL be removed.
#### Scenario: Generate migrations
- **WHEN** `drizzle-kit generate` is executed
- **THEN** a new SQL migration file is created in the `drizzle/` directory with PostgreSQL-dialect DDL
#### Scenario: Apply migrations at startup
- **WHEN** the application starts (not during build phase)
- **THEN** Drizzle runs pending migrations against the PostgreSQL database
#### Scenario: Skip migrations during build
- **WHEN** `NEXT_PHASE` is `phase-production-build` or `phase-development-build`
- **THEN** migration execution is skipped
### Requirement: npm dependency changes
The project SHALL remove `better-sqlite3` and `@types/better-sqlite3` from dependencies and add `pg` and `@types/pg`.
#### Scenario: Dependencies updated
- **WHEN** `package.json` is inspected
- **THEN** `better-sqlite3` and `@types/better-sqlite3` are absent, and `pg` and `@types/pg` are present in dependencies
### Requirement: Data migration from SQLite to PostgreSQL
The project SHALL include a one-time migration script at `scripts/migrate-sqlite-to-postgres.ts` that reads all data from the SQLite database and inserts it into PostgreSQL with appropriate type conversions.
#### Scenario: Migrate all tables
- **WHEN** the migration script is executed with both databases accessible
- **THEN** all rows from charts, candles, annotation_types, annotations, span_label_types, and span_annotations are transferred to PostgreSQL
#### Scenario: Type conversions applied
- **WHEN** data is migrated
- **THEN** SQLite integer timestamps are converted to PostgreSQL timestamps, integer booleans (0/1) are converted to PostgreSQL booleans, and text JSON fields are inserted as jsonb
#### Scenario: Idempotent execution
- **WHEN** the migration script is run a second time on an already-migrated database
- **THEN** the script either skips existing data or clears and re-inserts (with a flag), without creating duplicates

View file

@ -1,53 +0,0 @@
## 1. Dependencies and Configuration
- [x] 1.1 Remove `better-sqlite3` and `@types/better-sqlite3` from package.json
- [x] 1.2 Add `pg` and `@types/pg` to package.json dependencies
- [x] 1.3 Run `npm install` to update node_modules and lockfile
- [x] 1.4 Update `drizzle.config.ts` to target `postgresql` dialect with `DATABASE_URL` env var
- [x] 1.5 Update `.env.example` — replace `DATABASE_PATH` with `DATABASE_URL=postgresql://ml_user:ml_password@postgres:5432/candle_annotator`
## 2. Drizzle Schema Migration (SQLite → PostgreSQL)
- [x] 2.1 Rewrite `src/lib/db/schema.ts` — replace all `sqliteTable` with `pgTable`, apply type mappings (integer→serial, integer→timestamp, integer→boolean, real→doublePrecision, text JSON→jsonb)
- [x] 2.2 Delete all existing SQLite migration files in `drizzle/` directory
- [x] 2.3 Run `drizzle-kit generate` to produce fresh PostgreSQL migration SQL
- [x] 2.4 Review generated migration SQL for correctness
## 3. Database Connection Layer
- [x] 3.1 Rewrite `src/lib/db/index.ts` — replace `better-sqlite3` driver with `pg.Pool` (max: 10), read `DATABASE_URL` from env, fail if missing
- [x] 3.2 Update migration runner to use PostgreSQL-compatible execution (skip during build phase via `NEXT_PHASE` check)
- [x] 3.3 Update all imports if any changed (verify `db` export still works for API routes)
## 4. API Route Adjustments
- [x] 4.1 Audit all Next.js API routes using `db` for SQLite-specific syntax (e.g., integer booleans, raw SQL fragments)
- [x] 4.2 Fix any SQLite-specific query patterns to work with PostgreSQL (boolean handling, timestamp handling, jsonb operations)
- [x] 4.3 Update health check endpoint (`/api/health`) to verify PostgreSQL connectivity
## 5. Docker and Deployment
- [x] 5.1 Update `docker-compose.yml` — rename `POSTGRES_DB` to `candle_annotator`, add `DATABASE_URL` env to candle-annotator service, add `depends_on: postgres` with health check condition
- [x] 5.2 Remove `candle-data` volume from `docker-compose.yml` (SQLite volume)
- [x] 5.3 Update `Dockerfile` if it references SQLite or `DATABASE_PATH`
- [x] 5.4 Update ML service database connection — change database name from `ml_db` to `candle_annotator` in environment config
## 6. ML Service Direct Data Access
- [x] 6.1 Add SQLAlchemy table reflections or raw queries in the ML service for reading `candles`, `annotations`, `span_annotations`, `charts` tables
- [x] 6.2 Update ML training pipeline to query candle/annotation data from PostgreSQL instead of CSV/JSON exports
- [x] 6.3 Remove or deprecate any CSV/JSON export code paths that are no longer needed
## 7. Data Migration Script
- [x] 7.1 Create `scripts/migrate-sqlite-to-postgres.ts` — read all 6 tables from SQLite, apply type conversions (timestamps, booleans, JSON→jsonb), insert into PostgreSQL
- [x] 7.2 Make the script idempotent (skip or clear+re-insert with flag)
- [x] 7.3 Test migration script with existing SQLite data
## 8. Testing and Verification
- [x] 8.1 Run the full application locally with PostgreSQL — verify all API routes work
- [x] 8.2 Verify ML service can query candle/annotation data from shared database
- [x] 8.3 Run `docker compose up` and verify all services start correctly with new configuration
- [x] 8.4 Update `DEPLOYMENT.md` with new deployment steps (PostgreSQL migration, data migration script, rollback procedure)
- [x] 8.5 Update `README.md` and `CLAUDE_DESCRIPTION.md` with database architecture changes

View file

@ -0,0 +1,87 @@
## Context
The Candle Annotator has a fully implemented ML pipeline (feature engineering, annotation ingestion, training, inference) in a separate FastAPI service (`services/ml/`, port 8001), and a standalone TA-Lib pattern detection script (`generate_talib_annotations.py`). The Next.js frontend already has a `PredictionPanel` that can run predictions and display results, but TA-Lib pattern detection and model training are CLI-only operations.
The sidebar layout is a fixed 240px column with vertically stacked sections: ChartSelector → FileUpload → Toolbox → SpanAnnotationList → PredictionPanel → Export. All state lives in the root `Home` component and flows down as props.
## Goals / Non-Goals
**Goals:**
- Let users run TA-Lib CDL pattern detection from the sidebar and see results as span annotations on the chart
- Let users trigger ML training runs from the UI with model type and basic parameter selection
- Let users switch between trained models for prediction
- Keep the sidebar usable — panels must be collapsible since we're adding significant new UI surface
**Non-Goals:**
- Advanced hyperparameter tuning UI (grid search, cross-validation config)
- Real-time training progress (websockets) — polling is sufficient for now
- MLflow dashboard embedding — just link out to it
- Custom TA-Lib indicator configuration (RSI, EMA, etc.) — only CDL pattern functions
- Multi-chart training (training always uses the exported annotation dataset)
## Decisions
### 1. TA-Lib pattern detection runs server-side via new FastAPI endpoint
**Decision:** Add a `POST /patterns/detect` endpoint to the FastAPI service that accepts candle data and a list of pattern names, runs TA-Lib CDL functions, and returns span annotations.
**Rationale:** The `generate_talib_annotations.py` script already has the detection logic. Extracting it into a FastAPI endpoint reuses that code and keeps TA-Lib (C library) on the Python side only. The frontend sends candles the same way it does for `/predict`.
**Alternatives considered:**
- Running TA-Lib in a Next.js API route via child process — adds complexity, fragile
- Pre-computing all patterns on upload — wasteful, users want to select specific patterns
### 2. Detected patterns become span annotations with `source: "talib"`
**Decision:** When patterns are detected, save them directly as span annotations in the Next.js database via `POST /api/span-annotations` with `source: "talib"`. This allows bulk delete by source and makes them immediately visible on the chart.
**Rationale:** The `span_annotations` table already has a `source` field supporting arbitrary strings. Using `"talib"` as a new source value enables filtering and bulk operations without schema changes.
**Alternatives considered:**
- Separate "patterns" table — unnecessary duplication, patterns are just a special kind of span annotation
- Client-side only (no persistence) — lose patterns on page reload
### 3. Training triggered via new FastAPI endpoint, status via polling
**Decision:** Add `POST /training/start` (triggers training in a background thread) and `GET /training/runs` (returns training history from `training_runs` table). The frontend polls `/training/runs` every 5s while a run is active.
**Rationale:** Training takes minutes, not seconds. A background thread with DB status tracking is the simplest approach. The `training_runs` table already tracks status (`running`/`completed`). Polling is simpler than websockets and sufficient for an operation that runs infrequently.
**Alternatives considered:**
- Synchronous training endpoint — would time out (training takes minutes)
- Celery/Redis task queue — over-engineered for single-user tool
- WebSocket progress — complex, not worth it for rare operations
### 4. Model selection via training run list
**Decision:** The model selector shows completed training runs from the `training_runs` table. Selecting a model sends its `run_id` to a new `POST /model/load` endpoint that loads the model (from MLflow or local path). The prediction panel then reflects the newly loaded model.
**Rationale:** Training runs are already tracked with metadata (model type, metrics, timestamps). Each run produces a model artifact. Loading by run_id is unambiguous.
**Alternatives considered:**
- Scanning filesystem for `.pkl` files — fragile, no metadata
- MLflow model registry stages (staging/production) — too formal for this use case
### 5. Collapsible sidebar sections with accordion pattern
**Decision:** Wrap TA-Lib panel, Training panel, and Prediction panel in collapsible `<details>`/accordion sections. Only one ML-related panel open at a time is recommended but not enforced.
**Rationale:** Adding two new panels to an already full sidebar requires space management. Collapsible sections are the simplest approach, consistent with the existing compact sidebar design.
### 6. Next.js API routes as proxies (existing pattern)
**Decision:** All new FastAPI endpoints are proxied through Next.js API routes (`/api/patterns/*`, `/api/training/*`, `/api/model/*`), following the existing pattern used by `/api/predict` and `/api/model/info`.
**Rationale:** Keeps the frontend hitting a single origin, avoids CORS issues, and maintains consistency with the established architecture.
## Risks / Trade-offs
- **Training blocks the FastAPI process** — Training runs in a background thread sharing the same process. A long training run could affect prediction latency. → Mitigation: Training is rare and the tool is single-user. If it becomes a problem, move to subprocess.
- **Model hot-swap during prediction** — Loading a new model while a prediction request is in-flight could cause errors. → Mitigation: Use a lock around model swap; prediction requests wait briefly.
- **TA-Lib pattern detection on large datasets** — Running 50 patterns on 10k+ candles could be slow. → Mitigation: Let users select specific patterns rather than "run all". Show loading state.
- **Training data preparation** — Training requires an exported/labeled dataset CSV. The UI must make clear that training uses the annotation export, not the raw chart data. → Mitigation: Show which dataset file will be used, with candle/annotation counts.
## Open Questions
- Should we add a "run full pipeline" button (feature engineering → annotation ingestion → training) or keep these as separate steps?
- Should pattern detection results be auto-saved or require explicit "save to chart" action?

View file

@ -0,0 +1,31 @@
## Why
TA-Lib pattern recognition and ML model training/inference capabilities are fully implemented in the Python backend but require terminal commands to use. Users cannot select TA-Lib patterns, trigger training, or switch between trained models from the UI — making these powerful features inaccessible during normal annotation workflow.
## What Changes
- Add a **TA-Lib pattern panel** in the sidebar where users can select from the 50 implemented CDL pattern functions, run them on the current chart, and see results as span annotations
- Add ability to **bulk delete** TA-Lib-generated annotations (by source) or selectively keep them for ML training
- Add a **training panel** where users can select a model type (RandomForest, XGBoost), configure basic parameters, and trigger training from the UI
- Add a **model selector** to the existing prediction panel so users can switch between trained models and apply them to the current chart
- Add new API endpoints to support TA-Lib pattern detection and training triggers from the frontend
- Expose training run history and status in the UI
## Capabilities
### New Capabilities
- `talib-pattern-ui`: UI panel for selecting and running TA-Lib CDL pattern recognition functions on the current chart, viewing results as span annotations, and managing (keeping/deleting) detected patterns
- `training-ui`: UI panel for selecting model type, configuring parameters, triggering training runs, and viewing training history/status
- `model-selector`: UI for listing available trained models, switching the active model, and applying predictions to the current chart
### Modified Capabilities
- `prediction-ui`: Add model selection dropdown to existing prediction panel, integrate with model-selector for switching active model
- `backend-api`: New endpoints for TA-Lib pattern detection, training triggers, model listing, and training status
## Impact
- **Frontend**: New sidebar panels (TA-Lib patterns, training), modifications to PredictionPanel component
- **Backend API (Next.js)**: New proxy routes for TA-Lib and training endpoints
- **ML Service (FastAPI)**: New endpoints for pattern detection, training trigger, model listing
- **Database**: May need training_runs table exposure via API (already exists in PostgreSQL)
- **Dependencies**: No new dependencies — all TA-Lib and ML libraries already installed

View file

@ -0,0 +1,122 @@
## ADDED Requirements
### Requirement: Pattern detection endpoint
The FastAPI service SHALL provide a `POST /patterns/detect` endpoint that accepts candle data and a list of CDL pattern names. The endpoint SHALL run the specified TA-Lib CDL functions on the candle data and return detected patterns as span annotation objects. Each returned annotation SHALL include start_time, end_time, label, confidence, and source ("talib").
#### Scenario: Detect specific patterns
- **WHEN** `POST /patterns/detect` is called with `{candles: [...], patterns: ["CDLENGULFING", "CDLHAMMER"]}`
- **THEN** the endpoint runs only Engulfing and Hammer detection and returns matching span annotations
#### Scenario: Detect all patterns
- **WHEN** `POST /patterns/detect` is called with `{candles: [...], patterns: []}` (empty list)
- **THEN** the endpoint runs all available CDL pattern functions
#### Scenario: No patterns found
- **WHEN** detection runs but no patterns match
- **THEN** the endpoint returns `{annotations: [], metadata: {count: 0}}`
#### Scenario: Invalid pattern name
- **WHEN** a pattern name is not a valid TA-Lib CDL function
- **THEN** the endpoint returns HTTP 400 with the invalid pattern name in the error message
### Requirement: Available patterns endpoint
The FastAPI service SHALL provide a `GET /patterns/available` endpoint that returns the list of all supported CDL pattern names with their friendly display names.
#### Scenario: List available patterns
- **WHEN** `GET /patterns/available` is called
- **THEN** the endpoint returns a list of `{function_name, display_name}` for all supported CDL patterns
### Requirement: Training start endpoint
The FastAPI service SHALL provide a `POST /training/start` endpoint that triggers a training run in a background thread. The endpoint SHALL accept `{model_type}` and return immediately with a run_id and status "running". Only one training run SHALL be allowed at a time.
#### Scenario: Start training
- **WHEN** `POST /training/start` is called with `{model_type: "random_forest"}`
- **THEN** the endpoint returns `{run_id, status: "running"}` and training begins in the background
#### Scenario: Training already in progress
- **WHEN** `POST /training/start` is called while a training run is active
- **THEN** the endpoint returns HTTP 409 with `{error: "Training already in progress", run_id: "<active_run_id>"}`
#### Scenario: Invalid model type
- **WHEN** `POST /training/start` is called with an unsupported model type
- **THEN** the endpoint returns HTTP 400 with `{error: "Unsupported model type. Available: random_forest, xgboost"}`
### Requirement: Training runs endpoint
The FastAPI service SHALL provide a `GET /training/runs` endpoint that returns training run history from the database. Each entry SHALL include run_id, model_type, status, created_at, completed_at, and metrics_summary. Results SHALL be sorted by created_at descending.
#### Scenario: List training runs
- **WHEN** `GET /training/runs` is called
- **THEN** the endpoint returns training run records sorted by date descending
#### Scenario: No training runs
- **WHEN** no training runs exist in the database
- **THEN** the endpoint returns `{runs: []}`
### Requirement: Model load endpoint
The FastAPI service SHALL provide a `POST /model/load` endpoint that loads a model by run_id. The endpoint SHALL look up the training run, find the model artifact (MLflow or local), and replace the currently loaded model. The endpoint SHALL return the new model's info.
#### Scenario: Load model by run_id
- **WHEN** `POST /model/load` is called with `{run_id: "abc123"}`
- **THEN** the endpoint loads the model associated with that run, updates the active model, and returns model info
#### Scenario: Run not found
- **WHEN** `POST /model/load` is called with a non-existent run_id
- **THEN** the endpoint returns HTTP 404 with `{error: "Training run not found"}`
#### Scenario: Model artifact missing
- **WHEN** the training run exists but the model file is missing
- **THEN** the endpoint returns HTTP 500 with `{error: "Model artifact not found for run"}`
### Requirement: Dataset info endpoint
The FastAPI service SHALL provide a `GET /training/dataset-info` endpoint that returns information about the training dataset: file path, existence status, file size, and last modified date.
#### Scenario: Dataset exists
- **WHEN** `GET /training/dataset-info` is called and the labeled dataset file exists
- **THEN** the endpoint returns `{path, exists: true, size_bytes, last_modified, row_count}`
#### Scenario: Dataset missing
- **WHEN** `GET /training/dataset-info` is called and the labeled dataset file does not exist
- **THEN** the endpoint returns `{path, exists: false}`
### Requirement: Pattern detection proxy
The Next.js API SHALL provide a `POST /api/patterns/detect` route that proxies to the FastAPI `/patterns/detect` endpoint.
#### Scenario: Proxy pattern detection
- **WHEN** `POST /api/patterns/detect` is called
- **THEN** the route forwards the request to the FastAPI service and returns the response
### Requirement: Available patterns proxy
The Next.js API SHALL provide a `GET /api/patterns/available` route that proxies to the FastAPI `/patterns/available` endpoint.
#### Scenario: Proxy available patterns
- **WHEN** `GET /api/patterns/available` is called
- **THEN** the route forwards to the FastAPI service and returns the pattern list
### Requirement: Training proxy endpoints
The Next.js API SHALL provide proxy routes for training operations: `POST /api/training/start`, `GET /api/training/runs`, and `GET /api/training/dataset-info`.
#### Scenario: Proxy training start
- **WHEN** `POST /api/training/start` is called
- **THEN** the route forwards to the FastAPI service and returns the response
#### Scenario: Proxy training runs
- **WHEN** `GET /api/training/runs` is called
- **THEN** the route forwards to the FastAPI service and returns the run list
### Requirement: Model load proxy
The Next.js API SHALL provide a `POST /api/model/load` route that proxies to the FastAPI `/model/load` endpoint.
#### Scenario: Proxy model load
- **WHEN** `POST /api/model/load` is called with a run_id
- **THEN** the route forwards to the FastAPI service and returns the response
### Requirement: Bulk delete by source
The Next.js API `DELETE /api/span-annotations` endpoint SHALL support a `source` query parameter for bulk deletion. When `source` is provided, all span annotations matching that source (and optionally `label` filter) for the current chart SHALL be deleted.
#### Scenario: Bulk delete TA-Lib annotations
- **WHEN** `DELETE /api/span-annotations?chartId=1&source=talib` is called
- **THEN** all span annotations with `source: "talib"` for chart 1 are deleted
#### Scenario: Bulk delete by source and label
- **WHEN** `DELETE /api/span-annotations?chartId=1&source=talib&label=Engulfing` is called
- **THEN** only TA-Lib annotations containing "Engulfing" in the label for chart 1 are deleted

View file

@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Model selector dropdown
The system SHALL display a model selector dropdown in the prediction panel area. The dropdown SHALL list all completed training runs from the backend, showing model type, date, and key metric (F1 macro) for each entry. The currently loaded model SHALL be indicated with a checkmark or "active" badge.
#### Scenario: Display available models
- **WHEN** the user opens the model selector dropdown
- **THEN** completed training runs are listed with model type, training date, and F1 score
#### Scenario: No models available
- **WHEN** no completed training runs exist
- **THEN** the dropdown shows "No trained models available"
#### Scenario: Current model indicated
- **WHEN** a model is currently loaded
- **THEN** the corresponding entry in the dropdown shows an "active" indicator
### Requirement: Model switching
The system SHALL load a different model when the user selects a training run from the model selector. The system SHALL send the run_id to `POST /api/model/load` and update the prediction panel to reflect the newly loaded model's info. Existing cached predictions SHALL be cleared on model switch.
#### Scenario: Switch model
- **WHEN** the user selects a different model from the dropdown
- **THEN** the system sends `POST /api/model/load` with the run_id, shows a loading indicator, and upon success updates the model info display and clears prediction cache
#### Scenario: Model load failure
- **WHEN** model loading fails (e.g., model artifact not found)
- **THEN** the system shows an error message and keeps the previously loaded model active
#### Scenario: Prediction cache cleared
- **WHEN** a new model is successfully loaded
- **THEN** all cached predictions are invalidated and the chart clears any displayed predictions
### Requirement: Model info refresh on switch
The system SHALL refresh the model info display (name, version, type, per-class metrics) after a successful model switch. The prediction panel SHALL reflect the new model's capabilities and labels.
#### Scenario: Model info updates after switch
- **WHEN** a new model is loaded successfully
- **THEN** the prediction panel refreshes to show the new model's name, type, version, and per-class metrics

View file

@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Prediction controls panel
The system SHALL display a prediction controls panel in the sidebar with: master on/off toggle, model selector dropdown (listing available trained models), model info (name, version, type, training date), action buttons ("Run on Visible", "Predict All"), auto-predict toggle, confidence threshold slider, label checkboxes with per-class precision/recall metrics, prediction count, agreement count, and a "Show only disagreements" filter.
#### Scenario: Display model info
- **WHEN** the prediction panel loads and the inference API is available
- **THEN** the panel fetches /api/model/info and displays model name, version, type, and training date
#### Scenario: Inference API unavailable
- **WHEN** the prediction panel loads and /api/model/info returns an error
- **THEN** the panel shows "Model server offline — predictions unavailable" and all controls are disabled
#### Scenario: Per-class metrics display
- **WHEN** model info includes per-class metrics
- **THEN** each label checkbox shows precision and recall values (e.g., "bull_flag (P:0.89 R:0.76)")
#### Scenario: Model selector integrated
- **WHEN** the prediction panel renders with trained models available
- **THEN** a model selector dropdown appears above the action buttons, allowing the user to switch the active model
### Requirement: Prediction cache invalidation on model change
The system SHALL cache predictions in memory keyed by `${pair}_${timeframe}_${startTime}_${endTime}_${modelVersion}`. When the user scrolls to a range with cached predictions, the system SHALL use the cache instead of re-fetching. Cache SHALL be invalidated when the model version changes OR when the user switches models via the model selector.
#### Scenario: Cache hit
- **WHEN** user scrolls back to a previously predicted range with the same model version
- **THEN** the system renders cached predictions without making an API call
#### Scenario: Cache invalidation on model switch
- **WHEN** the user switches to a different model via the model selector
- **THEN** all cached predictions are cleared and the chart removes displayed predictions

View file

@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: TA-Lib pattern selection panel
The system SHALL display a collapsible "TA-Lib Patterns" panel in the sidebar. The panel SHALL list all available CDL pattern functions grouped by category (single-candle, multi-candle). Each pattern SHALL have a checkbox for selection. The panel SHALL include a "Select All" / "Deselect All" toggle and a "Detect Patterns" action button.
#### Scenario: Display available patterns
- **WHEN** the user expands the TA-Lib Patterns panel
- **THEN** all available CDL pattern names are listed with checkboxes, grouped by category, all unchecked by default
#### Scenario: Select specific patterns
- **WHEN** the user checks "Engulfing" and "Hammer" checkboxes
- **THEN** those two patterns are selected and the "Detect Patterns" button shows the count "(2 selected)"
#### Scenario: Select all patterns
- **WHEN** the user clicks "Select All"
- **THEN** all pattern checkboxes become checked
### Requirement: Pattern detection execution
The system SHALL send selected patterns and current chart candles to the backend for detection when the user clicks "Detect Patterns". The system SHALL display a loading state during detection and show results as span annotations on the chart upon completion.
#### Scenario: Run pattern detection
- **WHEN** the user has selected patterns and clicks "Detect Patterns"
- **THEN** the system sends current chart candles and selected pattern names to `POST /api/patterns/detect`, shows a loading spinner, and upon response saves returned annotations to the database via `POST /api/span-annotations`
#### Scenario: No patterns selected
- **WHEN** the user clicks "Detect Patterns" with no patterns selected
- **THEN** the button is disabled and nothing happens
#### Scenario: No chart loaded
- **WHEN** the user clicks "Detect Patterns" with no chart loaded
- **THEN** the system shows an error message "Load a chart first"
### Requirement: Pattern results as span annotations
The system SHALL save detected patterns as span annotations with `source: "talib"`. Each annotation SHALL include the pattern label (e.g., "Bullish Engulfing"), confidence from TA-Lib, start_time, and end_time. The annotations SHALL appear immediately on the chart after detection.
#### Scenario: Pattern saved as span annotation
- **WHEN** TA-Lib detects a "Bullish Engulfing" pattern at candles T10-T12
- **THEN** a span annotation is created with `label: "Bullish Engulfing"`, `source: "talib"`, `start_time: T10`, `end_time: T12`, and it renders on the chart
#### Scenario: Multiple patterns detected
- **WHEN** detection finds 5 Engulfing and 3 Hammer patterns
- **THEN** 8 span annotations are created, each with appropriate label and timestamps
### Requirement: TA-Lib annotation management
The system SHALL allow users to bulk delete all TA-Lib-generated annotations or delete them by pattern type. The panel SHALL show a count of detected patterns and a "Clear All TA-Lib" button. Individual TA-Lib annotations SHALL also be deletable from the SpanAnnotationList.
#### Scenario: Bulk delete all TA-Lib annotations
- **WHEN** the user clicks "Clear All TA-Lib"
- **THEN** all span annotations with `source: "talib"` for the current chart are deleted
#### Scenario: Delete by pattern type
- **WHEN** the user clicks the delete icon next to "Engulfing" in the results summary
- **THEN** all span annotations with `source: "talib"` and label containing "Engulfing" for the current chart are deleted
#### Scenario: Detection results summary
- **WHEN** pattern detection completes with results
- **THEN** the panel shows a summary: total patterns found, grouped by pattern name with counts (e.g., "Engulfing: 5, Hammer: 3")

View file

@ -0,0 +1,60 @@
## ADDED Requirements
### Requirement: Training panel
The system SHALL display a collapsible "Training" panel in the sidebar. The panel SHALL contain model type selection, a "Start Training" button, and training run history. The panel SHALL be usable independently of the prediction panel.
#### Scenario: Display training panel
- **WHEN** the user expands the Training panel
- **THEN** the panel shows model type selector, training action button, and recent training history
### Requirement: Model type selection
The system SHALL provide a dropdown to select the ML model type for training. Available options SHALL be "Random Forest" and "XGBoost". The selection SHALL default to "Random Forest".
#### Scenario: Select model type
- **WHEN** the user opens the model type dropdown
- **THEN** "Random Forest" and "XGBoost" are listed as options
#### Scenario: Default selection
- **WHEN** the training panel loads
- **THEN** "Random Forest" is pre-selected
### Requirement: Training execution
The system SHALL trigger a training run via `POST /api/training/start` when the user clicks "Start Training". The request SHALL include the selected model type. The system SHALL show a progress indicator and poll for status updates every 5 seconds while training is active. The "Start Training" button SHALL be disabled while a training run is in progress.
#### Scenario: Start training
- **WHEN** the user selects "XGBoost" and clicks "Start Training"
- **THEN** the system sends `POST /api/training/start` with `{model_type: "xgboost"}`, disables the button, and shows a "Training in progress..." indicator
#### Scenario: Training completes
- **WHEN** a training run status changes from "running" to "completed"
- **THEN** the system shows a success message with key metrics (accuracy, F1 score), re-enables the button, and adds the run to training history
#### Scenario: Training fails
- **WHEN** a training run status changes to "failed"
- **THEN** the system shows an error message with the failure reason and re-enables the button
#### Scenario: Prevent concurrent training
- **WHEN** a training run is already in progress and the user tries to start another
- **THEN** the "Start Training" button remains disabled
### Requirement: Training run history
The system SHALL display a list of recent training runs fetched from `GET /api/training/runs`. Each entry SHALL show: model type, status, date, and key metrics (accuracy, F1 macro). The list SHALL be sorted by date descending (most recent first). The list SHALL show the 5 most recent runs.
#### Scenario: Display training history
- **WHEN** the training panel loads
- **THEN** the system fetches training runs and displays them with model type, status badge, date, and metrics
#### Scenario: No training runs
- **WHEN** no training runs exist
- **THEN** the panel shows "No training runs yet"
### Requirement: Dataset info display
The system SHALL display information about the training dataset before training starts. This SHALL include the dataset file path and whether it exists. The user SHALL be informed that training uses the exported annotation dataset.
#### Scenario: Dataset available
- **WHEN** the training panel loads and the labeled dataset exists
- **THEN** the panel shows the dataset path and a ready indicator
#### Scenario: Dataset missing
- **WHEN** the training panel loads and no labeled dataset exists
- **THEN** the panel shows a warning "No training dataset found. Export annotations first." and the "Start Training" button is disabled

View file

@ -7,25 +7,25 @@
## 2. FastAPI Training Endpoints
- [ ] 2.1 Add `POST /training/start` endpoint that launches training in a background thread, returns `{run_id, status: "running"}`, and rejects concurrent runs with HTTP 409
- [ ] 2.2 Add `GET /training/runs` endpoint returning training run history from the `training_runs` table, sorted by date descending
- [ ] 2.3 Add `GET /training/dataset-info` endpoint returning labeled dataset file path, existence, size, and row count
- [ ] 2.4 Add background training thread management: track active run, update DB status on completion/failure
- [x] 2.1 Add `POST /training/start` endpoint that launches training in a background thread, returns `{run_id, status: "running"}`, and rejects concurrent runs with HTTP 409
- [x] 2.2 Add `GET /training/runs` endpoint returning training run history from the `training_runs` table, sorted by date descending
- [x] 2.3 Add `GET /training/dataset-info` endpoint returning labeled dataset file path, existence, size, and row count
- [x] 2.4 Add background training thread management: track active run, update DB status on completion/failure
## 3. FastAPI Model Loading Endpoint
- [ ] 3.1 Add `POST /model/load` endpoint accepting `{run_id}`, looking up the training run, loading the model artifact, and replacing the active model in `AppState`
- [ ] 3.2 Add thread-safe model swap with locking to prevent conflicts with in-flight prediction requests
- [x] 3.1 Add `POST /model/load` endpoint accepting `{run_id}`, looking up the training run, loading the model artifact, and replacing the active model in `AppState`
- [x] 3.2 Add thread-safe model swap with locking to prevent conflicts with in-flight prediction requests
## 4. Next.js Proxy Routes
- [ ] 4.1 Add `GET /api/patterns/available` proxy route
- [ ] 4.2 Add `POST /api/patterns/detect` proxy route
- [ ] 4.3 Add `POST /api/training/start` proxy route
- [ ] 4.4 Add `GET /api/training/runs` proxy route
- [ ] 4.5 Add `GET /api/training/dataset-info` proxy route
- [ ] 4.6 Add `POST /api/model/load` proxy route
- [ ] 4.7 Extend `DELETE /api/span-annotations` to support `source` and `label` query parameters for bulk deletion
- [x] 4.1 Add `GET /api/patterns/available` proxy route
- [x] 4.2 Add `POST /api/patterns/detect` proxy route
- [x] 4.3 Add `POST /api/training/start` proxy route
- [x] 4.4 Add `GET /api/training/runs` proxy route
- [x] 4.5 Add `GET /api/training/dataset-info` proxy route
- [x] 4.6 Add `POST /api/model/load` proxy route
- [x] 4.7 Extend `DELETE /api/span-annotations` to support `source` and `label` query parameters for bulk deletion
## 5. TA-Lib Pattern UI Panel

View file

@ -5,6 +5,8 @@ Provides REST API endpoints for model serving, health checks, and prediction.
"""
import logging
import threading
import uuid as uuid_lib
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime
@ -19,8 +21,10 @@ import joblib
import mlflow
import mlflow.sklearn
import mlflow.xgboost
from sqlalchemy import update as sa_update, desc
from app.config import load_config, PipelineConfig
from app.config import load_config, PipelineConfig, get_default_config
from app.db import get_db, TrainingRun
from app.preprocessing import preprocess_candles, extract_feature_columns
from app.patterns import (
TALIB_PATTERNS,
@ -60,7 +64,13 @@ class AppState:
pipeline_config: Optional[PipelineConfig] = None
feature_columns: Optional[List[str]] = None
label_encoder: Optional[Dict[int, str]] = None
# Training thread management
active_training_run_id: Optional[str] = None
training_lock: threading.Lock = None
def __init__(self):
self.training_lock = threading.Lock()
state = AppState()
@ -826,6 +836,413 @@ async def patterns_detect(request: DetectPatternsRequest):
)
# ---------------------------------------------------------------------------
# Training Endpoints
# ---------------------------------------------------------------------------
SUPPORTED_MODEL_TYPES = ["random_forest", "xgboost"]
class TrainingStartRequest(BaseModel):
"""Request model for POST /training/start."""
model_type: str = Field(
"random_forest",
description="Model type: random_forest or xgboost",
)
class TrainingStartResponse(BaseModel):
"""Response model for POST /training/start."""
run_id: str
status: str
class TrainingRunInfo(BaseModel):
"""Summary of a single training run."""
run_id: str
model_type: str
status: str
experiment_name: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
metrics_summary: Optional[Dict[str, Any]] = None
class TrainingRunsResponse(BaseModel):
"""Response model for GET /training/runs."""
runs: List[TrainingRunInfo]
class DatasetInfoResponse(BaseModel):
"""Response model for GET /training/dataset-info."""
path: str
exists: bool
size_bytes: Optional[int] = None
last_modified: Optional[str] = None
row_count: Optional[int] = None
def _run_training_background(run_id: str, model_type: str, config: PipelineConfig) -> None:
"""
Background thread target: train a model, update DB on completion or failure.
Uses the pre-inserted TrainingRun record identified by ``run_id``.
"""
logger.info(f"Training thread started: run_id={run_id}, model_type={model_type}")
try:
# Import training utilities here to avoid circular import issues
from training.train import create_model, temporal_split
from sklearn.metrics import accuracy_score, f1_score
labeled_path = Path(config.data.labeled_path)
if not labeled_path.exists():
raise FileNotFoundError(f"Labeled dataset not found: {labeled_path}")
# Load dataset
df = pd.read_csv(labeled_path)
if "label" not in df.columns:
raise ValueError("Labeled dataset must have 'label' column")
feature_cols = [
col for col in df.columns
if col not in ("label", "time", "timestamp")
and not col.startswith("label_programmatic_")
]
X = df[feature_cols].values
y = df["label"].values
logger.info(f"Loaded {len(X)} samples, {len(feature_cols)} features")
# Split data
training_cfg = config.stages.training
X_train, X_val, X_test, y_train, y_val, y_test = temporal_split(
X, y, training_cfg.test_split, training_cfg.validation_split
)
# Train model
model_instance = create_model(
model_type, training_cfg.hyperparameters, training_cfg.class_weights
)
model_instance.fit(X_train, y_train)
logger.info("Model training complete")
# Evaluate
y_val_pred = model_instance.predict(X_val)
y_test_pred = model_instance.predict(X_test)
metrics = {
"val_accuracy": float(accuracy_score(y_val, y_val_pred)),
"val_f1_macro": float(
f1_score(y_val, y_val_pred, average="macro", zero_division=0)
),
"test_accuracy": float(accuracy_score(y_test, y_test_pred)),
"test_f1_macro": float(
f1_score(y_test, y_test_pred, average="macro", zero_division=0)
),
"n_samples": int(len(X)),
"n_features": int(X.shape[1]),
}
# Save model locally
models_dir = Path("models")
models_dir.mkdir(exist_ok=True)
model_path = models_dir / f"{run_id}.pkl"
model_data = {
"model": model_instance,
"metadata": {
"model_type": model_type,
"trained_at": datetime.utcnow().isoformat(),
"run_id": run_id,
"feature_columns": feature_cols,
"labels": (
[str(c) for c in model_instance.model.classes_]
if hasattr(model_instance, "model") and hasattr(model_instance.model, "classes_")
else []
),
},
}
joblib.dump(model_data, model_path)
logger.info(f"Model saved to {model_path}")
# Update DB: completed
with get_db() as db:
stmt = (
sa_update(TrainingRun)
.where(TrainingRun.run_id == run_id)
.values(
status="completed",
completed_at=datetime.utcnow(),
metrics_summary=metrics,
)
)
db.execute(stmt)
db.commit()
logger.info(f"Training completed successfully: run_id={run_id}")
except Exception as exc:
logger.error(f"Training failed for run_id={run_id}: {exc}", exc_info=True)
try:
with get_db() as db:
stmt = (
sa_update(TrainingRun)
.where(TrainingRun.run_id == run_id)
.values(
status="failed",
completed_at=datetime.utcnow(),
metrics_summary={"error": str(exc)},
)
)
db.execute(stmt)
db.commit()
except Exception as db_exc:
logger.error(f"Failed to update DB for failed run {run_id}: {db_exc}")
finally:
with state.training_lock:
if state.active_training_run_id == run_id:
state.active_training_run_id = None
logger.info(f"Training thread exiting: run_id={run_id}")
@app.post("/training/start", response_model=TrainingStartResponse)
async def training_start(request: TrainingStartRequest):
"""
Start a training run in a background thread.
Returns immediately with run_id and status "running".
Rejects concurrent runs with HTTP 409.
"""
# Validate model type
if request.model_type not in SUPPORTED_MODEL_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported model type. Available: {', '.join(SUPPORTED_MODEL_TYPES)}",
)
# Reject concurrent runs (atomic check-and-set)
with state.training_lock:
if state.active_training_run_id is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": "Training already in progress",
"run_id": state.active_training_run_id,
},
)
run_id = str(uuid_lib.uuid4())
state.active_training_run_id = run_id
config = state.pipeline_config or get_default_config()
# Compute config hash (best-effort)
config_hash = "unknown"
try:
from training.train import compute_config_hash
config_hash = compute_config_hash(config)
except Exception:
pass
# Pre-insert the run record so callers can track it immediately
try:
with get_db() as db:
training_run = TrainingRun(
run_id=run_id,
model_type=request.model_type,
experiment_name=config.stages.training.mlflow.experiment_name,
pipeline_config_hash=config_hash,
status="running",
created_at=datetime.utcnow(),
metrics_summary={},
)
db.add(training_run)
db.commit()
except Exception as exc:
with state.training_lock:
state.active_training_run_id = None
logger.error(f"Failed to insert training run record: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create training run record: {exc}",
)
# Launch background thread (daemon so it doesn't block process exit)
thread = threading.Thread(
target=_run_training_background,
args=(run_id, request.model_type, config),
daemon=True,
name=f"training-{run_id[:8]}",
)
thread.start()
logger.info(f"Training started: run_id={run_id}, model_type={request.model_type}")
return TrainingStartResponse(run_id=run_id, status="running")
@app.get("/training/runs", response_model=TrainingRunsResponse)
async def training_runs():
"""
Return training run history from the database, sorted by date descending.
"""
try:
from sqlalchemy import select
with get_db() as db:
stmt = select(TrainingRun).order_by(desc(TrainingRun.created_at))
rows = db.execute(stmt).scalars().all()
runs = [
TrainingRunInfo(
run_id=row.run_id,
model_type=row.model_type,
status=row.status,
experiment_name=row.experiment_name,
created_at=row.created_at.isoformat() if row.created_at else None,
completed_at=row.completed_at.isoformat() if row.completed_at else None,
metrics_summary=row.metrics_summary,
)
for row in rows
]
except Exception as exc:
logger.error(f"Failed to fetch training runs: {exc}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch training runs: {exc}",
)
return TrainingRunsResponse(runs=runs)
@app.get("/training/dataset-info", response_model=DatasetInfoResponse)
async def training_dataset_info():
"""
Return information about the labeled training dataset.
Includes file path, existence, size, last modified date, and row count.
"""
config = state.pipeline_config or get_default_config()
labeled_path = Path(config.data.labeled_path)
if not labeled_path.exists():
return DatasetInfoResponse(path=str(labeled_path), exists=False)
try:
stat = labeled_path.stat()
size_bytes = stat.st_size
last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
row_count = None
try:
# Read only one column for efficiency
df_head = pd.read_csv(labeled_path, usecols=[0])
row_count = len(df_head)
except Exception:
pass
return DatasetInfoResponse(
path=str(labeled_path),
exists=True,
size_bytes=size_bytes,
last_modified=last_modified,
row_count=row_count,
)
except Exception as exc:
logger.error(f"Failed to get dataset info: {exc}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get dataset info: {exc}",
)
# ---------------------------------------------------------------------------
# Model Loading Endpoint
# ---------------------------------------------------------------------------
class ModelLoadRequest(BaseModel):
"""Request model for POST /model/load."""
run_id: str = Field(..., description="Training run ID to load model from")
class ModelLoadResponse(BaseModel):
"""Response model for POST /model/load."""
run_id: str
model_type: str
status: str
# Lock protecting model hot-swap
_model_swap_lock = threading.Lock()
@app.post("/model/load", response_model=ModelLoadResponse)
async def model_load(request: ModelLoadRequest):
"""
Load a trained model from a completed training run.
Looks up the run_id in the training_runs table, loads the model artifact,
and replaces the active model in AppState with a brief lock to prevent
conflicts with in-flight prediction requests.
"""
from sqlalchemy import select
# 1. Look up the training run
try:
with get_db() as db:
stmt = select(TrainingRun).where(TrainingRun.run_id == request.run_id)
row = db.execute(stmt).scalar_one_or_none()
except Exception as exc:
logger.error(f"DB lookup failed for run_id={request.run_id}: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Database error: {exc}",
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Training run not found: {request.run_id}",
)
if row.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Training run is not completed (status={row.status})",
)
# 2. Resolve model artifact path
model_path = Path("models") / f"{request.run_id}.pkl"
if not model_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Model artifact not found at {model_path}",
)
# 3. Load model (outside lock can be slow)
try:
new_model, new_model_info = load_model_from_local(str(model_path))
except Exception as exc:
logger.error(f"Failed to load model for run_id={request.run_id}: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to load model: {exc}",
)
# 4. Thread-safe model swap (3.2 brief lock)
with _model_swap_lock:
state.model = new_model
state.model_info = new_model_info
logger.info(f"Model hot-swapped: run_id={request.run_id}, type={row.model_type}")
return ModelLoadResponse(
run_id=request.run_id,
model_type=row.model_type,
status="loaded",
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
const INFERENCE_API_URL = process.env.INFERENCE_API_URL || 'http://localhost:8001';
const INFERENCE_API_TIMEOUT = parseInt(process.env.INFERENCE_API_TIMEOUT || '30000', 10);
export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), INFERENCE_API_TIMEOUT);
try {
const body = await request.json();
const response = await fetch(`${INFERENCE_API_URL}/model/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return NextResponse.json({ error: data.detail || 'Failed to load model' }, { status: response.status });
}
return NextResponse.json(data);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return NextResponse.json({ error: 'Model load timed out' }, { status: 504 });
}
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
return NextResponse.json({ error: 'Inference service unavailable' }, { status: 503 });
}
console.error('model/load proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
const INFERENCE_API_URL = process.env.INFERENCE_API_URL || 'http://localhost:8001';
const INFERENCE_API_TIMEOUT = parseInt(process.env.INFERENCE_API_TIMEOUT || '10000', 10);
export async function GET(_request: NextRequest) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), INFERENCE_API_TIMEOUT);
try {
const response = await fetch(`${INFERENCE_API_URL}/patterns/available`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return NextResponse.json({ error: data.detail || 'Request failed' }, { status: response.status });
}
return NextResponse.json(data);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
return NextResponse.json({ error: 'Inference service unavailable' }, { status: 503 });
}
console.error('patterns/available proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
const INFERENCE_API_URL = process.env.INFERENCE_API_URL || 'http://localhost:8001';
// Pattern detection may take longer on large datasets
const INFERENCE_API_TIMEOUT = parseInt(process.env.INFERENCE_API_TIMEOUT || '60000', 10);
export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), INFERENCE_API_TIMEOUT);
try {
const body = await request.json();
const response = await fetch(`${INFERENCE_API_URL}/patterns/detect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return NextResponse.json({ error: data.detail || 'Pattern detection failed' }, { status: response.status });
}
return NextResponse.json(data);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return NextResponse.json({ error: 'Pattern detection timed out' }, { status: 504 });
}
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
return NextResponse.json({ error: 'Inference service unavailable' }, { status: 503 });
}
console.error('patterns/detect proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { spanAnnotations } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
import { eq, desc, and } from 'drizzle-orm';
// GET - List all span annotations for a chart
export async function GET(request: NextRequest) {
@ -87,3 +87,40 @@ export async function POST(request: NextRequest) {
);
}
}
// DELETE - Bulk delete span annotations by chartId + optional source/label filters
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const chartId = searchParams.get('chartId');
const source = searchParams.get('source');
const label = searchParams.get('label');
if (!chartId) {
return NextResponse.json(
{ error: 'chartId parameter is required' },
{ status: 400 }
);
}
const chartIdInt = parseInt(chartId);
// Build filter conditions
const conditions = [eq(spanAnnotations.chart_id, chartIdInt)];
if (source) conditions.push(eq(spanAnnotations.source, source));
if (label) conditions.push(eq(spanAnnotations.label, label));
const deleted = await db
.delete(spanAnnotations)
.where(and(...conditions))
.returning({ id: spanAnnotations.id });
return NextResponse.json({ deleted: deleted.length });
} catch (error) {
console.error('Error bulk deleting span annotations:', error);
return NextResponse.json(
{ error: 'Failed to delete span annotations' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
const INFERENCE_API_URL = process.env.INFERENCE_API_URL || 'http://localhost:8001';
const INFERENCE_API_TIMEOUT = parseInt(process.env.INFERENCE_API_TIMEOUT || '10000', 10);
export async function GET(_request: NextRequest) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), INFERENCE_API_TIMEOUT);
try {
const response = await fetch(`${INFERENCE_API_URL}/training/dataset-info`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return NextResponse.json({ error: data.detail || 'Failed to fetch dataset info' }, { status: response.status });
}
return NextResponse.json(data);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
return NextResponse.json({ error: 'Inference service unavailable' }, { status: 503 });
}
console.error('training/dataset-info proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
const INFERENCE_API_URL = process.env.INFERENCE_API_URL || 'http://localhost:8001';
const INFERENCE_API_TIMEOUT = parseInt(process.env.INFERENCE_API_TIMEOUT || '10000', 10);
export async function GET(_request: NextRequest) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), INFERENCE_API_TIMEOUT);
try {
const response = await fetch(`${INFERENCE_API_URL}/training/runs`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return NextResponse.json({ error: data.detail || 'Failed to fetch training runs' }, { status: response.status });
}
return NextResponse.json(data);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
return NextResponse.json({ error: 'Inference service unavailable' }, { status: 503 });
}
console.error('training/runs proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
const INFERENCE_API_URL = process.env.INFERENCE_API_URL || 'http://localhost:8001';
const INFERENCE_API_TIMEOUT = parseInt(process.env.INFERENCE_API_TIMEOUT || '10000', 10);
export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), INFERENCE_API_TIMEOUT);
try {
const body = await request.json();
const response = await fetch(`${INFERENCE_API_URL}/training/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return NextResponse.json({ error: data.detail || 'Failed to start training' }, { status: response.status });
}
return NextResponse.json(data);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
return NextResponse.json({ error: 'Inference service unavailable' }, { status: 503 });
}
console.error('training/start proxy error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}