fix (ornith-35): watchlistRepository double-encoding bug — single JSON.stringify, 13/13 tests pass

This commit is contained in:
Investor Flow Build
2026-06-30 17:54:01 -04:00
parent 97607e0bd4
commit 1007ab4ed5
62 changed files with 11617 additions and 34 deletions
Vendored
BIN
View File
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
# .agent.md — Investor Flow project
## Autopilot
Autopilot: Enabled
## Routing
IF task type = research → load prompts/research.md + .rules.md
IF task type = design → load prompts/design.md + SPEC.md
IF task type = test_design → load prompts/test_design.md + SPEC.md + DESIGN.md
IF task type = implement → load prompts/implement.md + SPEC.md + DESIGN.md + TEST_PLAN.md + CONTRACT.md
IF task type = code_review → load prompts/code_review.md + SPEC.md + DESIGN.md + IMPLEMENTATION.md
IF task type = bug_find → load prompts/bug_finder.md + SPEC.md + code
IF task type = doc_review → load prompts/doc_review.md + DESIGN.md
IF task type = orchestrate → load prompts/orchestrate.md + project structure
## State Enforcement (v2.0)
All phase transitions must go through `status.py` (always pass `--project`):
- Create: `status.py --create-task {name} --project {project}`
- Transition: `status.py --transition {phase} --task {name} --project {project}`
- Approve: `status.py --approve --task {name} --project {project}`
- Audit: `status.py --audit --project {project}`
## Agent Configuration
Mode: multi-agent
Agents:
- id: ornith-35
phases: [research, decomposition, design, test_design, implement, code_review]
role: implementer
- id: qwopus35b
phases: [research, decomposition, design, test_design, implement, code_review]
role: implementer
- id: orchestrator
phases: [new, complete, human_intervention]
role: coordinator
Lock timeout: 30m
## Team (models — .automaton/models.json)
- ornith-35 (omlx/local, 131K) — implementer (primary; fast)
- qwopus35b (remote, 131K) — implementer (cross-reviews ornith-35's code; ornith-35 cross-reviews qwopus35b's)
- orchestrator — coordination ONLY (no code writing, no code review); dispatches implementers, assigns cross-reviews, enforces conflict-of-interest (no implementer reviews its own code).
+61
View File
@@ -0,0 +1,61 @@
# Automaton Framework — Issues Log (from Investor Flow pilot)
Discovered while piloting the automaton framework on a real 26-slice decomposition. Each issue is categorized for the self-improvement loop. The user is concurrently developing ~/.automaton in a separate session.
## P0 — Blocking: cannot dispatch implementation via pi
### Issue 1: Guard blocks ALL pi-driven edits on decomposed tasks
**Symptom:** `pi --print` headless dispatch (the only pi-native way to drive a model) is blocked by the automaton-guard-pi plugin. The guard resolves the "primary task" as the decomposed parent (alphabetically first among implement-phase tasks), finds `DECOMPOSITION.md`, and blocks: *"Task has a DECOMPOSITION.md — broken into sub-tasks for the loop runner."* Even if the guard resolved to the sub-task, it finds `PARENT_SPEC.md` and blocks: *"sub-task reserved for the loop runner."*
**Root cause:** The guard has no way to know WHICH sub-task a headless pi instance is working on. `checkCanEdit` calls `status.py --can-edit` without `--task`, so it picks the first implement-phase task (the parent). The guard's DECOMPOSITION.md/PARENT_SPEC.md checks then block both parent and sub-task paths.
**Impact:** Cannot use `pi --print --model <model>` to implement sub-tasks. The only dispatch path that works is the loop-runner daemon via `opencode run` — which uses a different model config than pi.
**Fix direction:** The guard should accept a `--task` argument from the pi process (e.g. via env var `AUTOMATON_TASK=<sub-task-name>`) so headless pi instances can declare which sub-task they're implementing. When `--task` points to a sub-task in implement phase, allow edits regardless of PARENT_SPEC.md.
### Issue 2: Loop runner only dispatches via opencode, not pi
**Symptom:** `loop-runner.py`'s `_invoke_harness` defaults to `["opencode", "run", "--dir", "{cwd}", "{prompt_content}"]`. There is no pi-native dispatch path.
**Impact:** Pi's configured models (omlx/Qwythos-9B, remote/ornith, etc.) are invisible to the loop runner. Opencode has its own separate model config (`~/.config/opencode/opencode.json`) with different provider names. To use pi-configured models, the user must either (a) mirror the config into opencode, or (b) the loop runner needs a pi dispatch mode.
**Fix direction:** Add a `pi` harness option in loop.json: `{"harness": {"command": ["pi", "--print", "--approve", "--model", "{model}", "{prompt_content}"]}}`. Or make the harness command configurable per-loop in the template.
## P1 — Phase graph friction
### Issue 3: design:approved cannot transition to decomposition
**Symptom:** `status.py --transition decomposition` from `design:approved` fails: *"Legal transitions from design:approved are: test_design, implement."* The canonical flow `research → decomposition → design` was skipped (we went `research → design`), and there's no way back.
**Impact:** DECOMPOSITION.md written during design phase is an "out-of-order artifact" that blocks transitions to test_design and implement. Required shuffling files in/out of the task folder to satisfy the gate.
**Fix direction:** Either (a) allow `design:approved → decomposition` as a legal transition, or (b) allow DECOMPOSITION.md as a valid artifact in the design phase (not just decomposition), or (c) document that decomposition must always precede design and enforce it at research:approved.
### Issue 4: Parent task stuck in implement after orchestrate_finalize
**Symptom:** `automaton_orchestrate_finalize` creates sub-tasks but fails to transition the parent to `complete`: *"Failed to transition parent task."* The parent stays in `implement` forever.
**Impact:** Dashboard shows the parent as a permanently in-progress task. Confusing for the user.
**Fix direction:** `automaton_orchestrate_finalize` should transition the parent to `complete` (or a new `decomposed` state) after sub-tasks are created.
## P2 — DX / usability
### Issue 5: dashboard.sh wrapper has a syntax error
**Symptom:** `dashboard.sh` uses `exec python3 -c "..."` without parentheses — Python 3 SyntaxError. Workaround: run `PYTHONPATH=~/.automaton python3 -m automaton.dashboard` directly.
**Fix:** Change `exec python3 -c` to `exec python3 -c` → actually the issue is bash `exec` syntax: `exec python3 -c "code"` should be `exec python3 -c "code"` — wait, the error is `SyntaxError: Missing parentheses in call to 'exec'` which is a Python error inside the heredoc, not bash. The inline Python code itself has a syntax error.
### Issue 6: No generic "implement-decomposition" loop template
**Symptom:** Only `ci-triage` and `self-improvement` loop templates ship. No template for "pick up the next new sub-task from an approved decomposition and implement it."
**Fix direction:** Ship an `implement-decomposition` loop template with `work_source: {kind: "decomposition"}` that picks the next `new` sub-task, transitions it through research→implement, dispatches the implementer, then advances to code_review.
### Issue 7: Slice sizing mismatch — vertical slices too large for single model dispatch
**Symptom:** A "vertical slice" in DECOMPOSITION.md (e.g. slice #1 = 20 files across schema/adapter/cache/queue/API/auth/UI/tests) is too large for one headless model dispatch. Both Qwythos-9B (drifted into meta-exploration) and Ornith-35B (read files but produced no code in 3 min) stalled.
**Fix direction:** The framework should support a "micro-dispatch" layer between slices and model dispatch — each slice decomposes into 1-4 file micro-dispatches sized for the model's coherence window. Or document this as an orchestrator responsibility (which is what we did in DESIGN.md Section 8).
### Issue 8: automaton_status shows project as "trader-flow" despite rename
**Symptom:** After renaming the repo dir `trader-flow → investor-flow` (with a symlink back), `automaton_status` still reports `Project: trader-flow`. The project name is cached somewhere and doesn't follow the symlink.
**Fix direction:** Resolve the project name from the real path (resolve symlink) or from `.automaton/project-name.md`.
## P3 — Documentation / expectations
### Issue 9: No documentation that the guard blocks pi --print on decomposed tasks
The guard is designed to reserve sub-tasks for "the loop runner" — but there's no documentation that this means pi --print CANNOT be used to implement sub-tasks, and that only the opencode-based loop runner can. This caused significant confusion during the pilot.
### Issue 10: automaton_orchestrate_finalize should warn about guard implications
When `automaton_orchestrate_finalize` creates sub-tasks with PARENT_SPEC.md, it should warn that these sub-tasks are now guard-blocked for direct pi edits and can only be worked via the loop runner.
- [ ] 🟠 Dead ternary in YFinanceAdapter (noted by qwopus35b backend review) — separate fix task TBD
- [ ] 🟡 Unused import in app/src/app/page.tsx (qwopus35b fix-spa review nit) — remove
- [ ] 🟡 Unused useRef import in app/src/app/page.tsx (qwopus35b fix-spa review nit) — remove
- [ ] 🟡 Unused `Defs` import in ChartLabPanel.tsx (qwopus35b re-review nit) — remove
- [ ] 🟡 Slice 16: add rate-limit timing test (qwopus35b approved w/ minor note) — follow-up
- [ ] 🟠 EdgarAdapter full_text_search does not cache ETags (qwopus found; filings_index/company_facts do) — fix adapter + tests still 1 failing
- [ ] 🟡 Slice 6: add tests for 13f_holdings + form4_tx (qwopus noted; bug fix approved, tests are follow-up)
+11
View File
@@ -0,0 +1,11 @@
# trader-flow — Automaton Configuration
## VRAM Configuration
- **Auto-detect**: Yes
- **Target context**: 58k tokens
- **Headroom**: 25%
- **Max peak context per sub-task**: 43k tokens
## Model Configuration
# Uses models.json for model divergence enforcement.
# Default model is read from models.json's "default" key.
+21
View File
@@ -0,0 +1,21 @@
{
"models": [
{
"name": "ornith-35",
"provider": "omlx",
"endpoint": "http://127.0.0.1:8000/v1",
"context": 131072,
"api": "openai-completions",
"role": "implement"
},
{
"name": "qwopus35b",
"provider": "remote",
"endpoint": "http://10.37.0.220:8080/v1",
"context": 131072,
"api": "openai-completions",
"role": "implement"
}
],
"default": "ornith-35"
}
+1
View File
@@ -0,0 +1 @@
trader-flow
+9
View File
@@ -0,0 +1,9 @@
# Automaton
.automaton/tasks/
.automaton/loops/*/worktree/
.automaton/loops/*/outputs/
# pi-vault-mind
.lancedb/
.obsidian/workspace*.json
.sessions/
View File
+10
View File
@@ -0,0 +1,10 @@
[AgentLoader] Loaded agent: broadcaster (type: BroadcasterAgent)
[AgentLoader] Loaded agent: heavy-lifter (type: HeavyLifterAgent)
[AgentLoader] Loaded agent: manager (type: ManagerAgent)
[AgentLoader] Loaded agent: miner (type: MinerAgent)
[pi-vault-mind] Agent engine started (4 agent(s) loaded)
[pi-vault-mind] Port 11435 in use; retrying on ephemeral port.
[pi-vault-mind] HTTP server listening on http://127.0.0.1:63167
[pi-vault-mind] Auto-starting watcher for 1 vault(s)
[pi-vault-mind] Watching vault "default" at /Users/laptran/.obsidian
[pi-vault-mind] Watcher started. Monitoring 1 vault(s).
+10
View File
@@ -0,0 +1,10 @@
[AgentLoader] Loaded agent: broadcaster (type: BroadcasterAgent)
[AgentLoader] Loaded agent: heavy-lifter (type: HeavyLifterAgent)
[AgentLoader] Loaded agent: manager (type: ManagerAgent)
[AgentLoader] Loaded agent: miner (type: MinerAgent)
[pi-vault-mind] Agent engine started (4 agent(s) loaded)
[pi-vault-mind] Port 11435 in use; retrying on ephemeral port.
[pi-vault-mind] HTTP server listening on http://127.0.0.1:63035
[pi-vault-mind] Auto-starting watcher for 1 vault(s)
[pi-vault-mind] Watching vault "default" at /Users/laptran/.obsidian
[pi-vault-mind] Watcher started. Monitoring 1 vault(s).
+13
View File
@@ -0,0 +1,13 @@
[AgentLoader] Loaded agent: broadcaster (type: BroadcasterAgent)
[AgentLoader] Loaded agent: heavy-lifter (type: HeavyLifterAgent)
[AgentLoader] Loaded agent: manager (type: ManagerAgent)
[AgentLoader] Loaded agent: miner (type: MinerAgent)
[pi-vault-mind] Agent engine started (4 agent(s) loaded)
[pi-vault-mind] Port 11435 in use; retrying on ephemeral port.
[pi-vault-mind] HTTP server listening on http://127.0.0.1:62879
[pi-vault-mind] Auto-starting watcher for 1 vault(s)
[pi-vault-mind] Watching vault "default" at /Users/laptran/.obsidian
[pi-vault-mind] Watcher started. Monitoring 1 vault(s).
[pi-vault-mind] Stopped watching vault "default".
[pi-vault-mind] Watcher stopped.
[pi-vault-mind] HTTP server stopped.
+13
View File
@@ -0,0 +1,13 @@
[AgentLoader] Loaded agent: broadcaster (type: BroadcasterAgent)
[AgentLoader] Loaded agent: heavy-lifter (type: HeavyLifterAgent)
[AgentLoader] Loaded agent: manager (type: ManagerAgent)
[AgentLoader] Loaded agent: miner (type: MinerAgent)
[pi-vault-mind] Agent engine started (4 agent(s) loaded)
[pi-vault-mind] Port 11435 in use; retrying on ephemeral port.
[pi-vault-mind] HTTP server listening on http://127.0.0.1:62861
[pi-vault-mind] Auto-starting watcher for 1 vault(s)
[pi-vault-mind] Watching vault "default" at /Users/laptran/.obsidian
[pi-vault-mind] Watcher started. Monitoring 1 vault(s).
[pi-vault-mind] Stopped watching vault "default".
[pi-vault-mind] Watcher stopped.
[pi-vault-mind] HTTP server stopped.
+13
View File
@@ -0,0 +1,13 @@
[AgentLoader] Loaded agent: broadcaster (type: BroadcasterAgent)
[AgentLoader] Loaded agent: heavy-lifter (type: HeavyLifterAgent)
[AgentLoader] Loaded agent: manager (type: ManagerAgent)
[AgentLoader] Loaded agent: miner (type: MinerAgent)
[pi-vault-mind] Agent engine started (4 agent(s) loaded)
[pi-vault-mind] Port 11435 in use; retrying on ephemeral port.
[pi-vault-mind] HTTP server listening on http://127.0.0.1:62508
[pi-vault-mind] Auto-starting watcher for 1 vault(s)
[pi-vault-mind] Watching vault "default" at /Users/laptran/.obsidian
[pi-vault-mind] Watcher started. Monitoring 1 vault(s).
[pi-vault-mind] Stopped watching vault "default".
[pi-vault-mind] Watcher stopped.
[pi-vault-mind] HTTP server stopped.
+1
View File
@@ -0,0 +1 @@
70294
+70
View File
@@ -0,0 +1,70 @@
You are a code implementer. Build ONE focused module. Write the code now.
## HARD RULES
- Do NOT call any automaton_* tool. Do NOT read PARENT_SPEC.md or DECOMPOSITION.md.
- Start writing files within your first 2 tool calls. Read only what's listed, then build.
- You are building micro-slice 1a: the SQLite database foundation for the Investor Flow backend.
## CONTEXT (read these, then build)
The project is "Investor Flow" — a Bun + SQLite backend serving a Next.js SPA. You are creating the database layer. The schema follows DESIGN.md Section 1 (Tier A shared market cache + Tier D system).
Read this ONE file for the exact schema:
`grep -n "Tier D\|Tier A\|CREATE TABLE\|users\|sessions\|symbol_meta\|price_quotes\|price_candles\|symbol_demand" /Users/laptran/Documents/investor-flow/.automaton/tasks/investor-flow-platform-design/DESIGN.md | head -40`
If the grep doesn't give enough, read lines 17-175 of that DESIGN.md.
## BUILD EXACTLY THESE 4 FILES
### 1. `app/server/package.json`
```json
{
"name": "investor-flow-server",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
"test": "bun test"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"@trpc/server": "^11.0.0",
"argon2": "^0.40.0",
"yahoo-finance2": "^2.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"bun-types": "^1.1.0",
"typescript": "^5.0.0"
}
}
```
### 2. `app/server/tsconfig.json`
Standard Bun TypeScript config (target ESNext, module ESNext, moduleResolution bundler, strict true, types ["bun-types"], outDir ./dist).
### 3. `app/server/src/db/schema.sql`
SQLite DDL for these tables (match DESIGN.md Section 1 Tier A + Tier D):
- `users` (id TEXT PK, email TEXT UNIQUE NOT NULL, pw_hash TEXT NOT NULL, complexity TEXT DEFAULT 'beginner', risk_tolerance TEXT, created_at TEXT NOT NULL)
- `sessions` (id TEXT PK, user_id TEXT NOT NULL REFERENCES users(id), expires_at TEXT NOT NULL, created_at TEXT NOT NULL)
- `symbol_meta` (symbol TEXT PK, name TEXT, sector TEXT, industry TEXT, exchange TEXT, ticker_kind TEXT NOT NULL DEFAULT 'equity', updated_at TEXT)
- `price_quotes` (symbol TEXT NOT NULL, price REAL, bid REAL, ask REAL, change REAL, change_pct REAL, ts_observed TEXT NOT NULL, fetched_at TEXT NOT NULL, PRIMARY KEY(symbol, ts_observed))
- `price_candles` (symbol TEXT NOT NULL, tf TEXT NOT NULL, ts TEXT NOT NULL, o REAL, h REAL, l REAL, c REAL, v REAL, adj_close REAL, PRIMARY KEY(symbol, tf, ts))
- `symbol_demand` (symbol TEXT PK, refcount INTEGER NOT NULL DEFAULT 0, protected INTEGER NOT NULL DEFAULT 0)
Add indexes: on price_quotes(symbol), price_candles(symbol, ts), sessions(user_id).
### 4. `app/server/src/db/client.ts`
A `DbClient` class (or factory function) that:
- Opens a SQLite database (better-sqlite3). Accepts a path arg; default `./investor-flow.db`. For tests, accept `:memory:`.
- On init, reads and executes `schema.sql` (use `import.meta.dir` to resolve the path relative to the file).
- Exposes the raw `Database` instance via a `db` property for other modules to use prepared statements.
- Exports a `createDb(path?)` factory.
TypeScript throughout. Use `better-sqlite3` synchronous API.
## THEN
Run `cd /Users/laptran/Documents/investor-flow/app/server && bun install` to install deps.
Then run a quick smoke test: `bun -e "import {createDb} from './src/db/client'; const db = createDb(':memory:'); console.log(db.db.prepare('SELECT name FROM sqlite_master WHERE type=\"table\"').all());"` to confirm tables exist.
Print the result. Then STOP — do not build anything else.
## PRIMARY RULE
No user-facing strings in this micro-slice (it's the DB layer). Just clean typed code.
+195
View File
@@ -0,0 +1,195 @@
# Investor Flow — DECOMPOSITION (vertical tracer-bullet slices)
Per the `to-issues` skill: each slice is a thin vertical path through ALL layers (schema, adapter, cache repo, API, UI, tests) — narrow but COMPLETE end-to-end, demoable on its own, fresh-context per slice. Slice #1 is the approved tracer bullet. Slices are listed in dependency order (blockers first).
The shared **tRPC integration seam** (Section 7.2) and **Primary-Rule lint** (ADR-0007) are established in slice #1 and extended by every slice. Cache-only testability (fakes at the seam) is required; nothing reaches the network in tests.
---
## Slice 1 — tracer-bullet-1: signup + cached NVDA overview [APPROVED]
**What to build:** User can sign up (email+password, no 2FA yet) + log in, and see a cached NVDA overview panel hydrated by one yfinance `quote` + `price_history`+`info/sector` adapter behind CacheRepository + AdapterQueue + a tRPC `market.snapshot` endpoint. Establishes: SQLite schema (Tier A price_quotes/price_candles/symbol_meta; Tier D users/sessions), one SourceAdapter (yfinance), CacheRepository staleness (quote 60s, sector weekly), single-page shell with active-symbol signal (NVDA hardcoded first), the tRPC seam, FakeLLM/FakeSourceAdapter test infra, and the Primary-Rule stub (landing page carries ADR-0007 footer).
**Acceptance criteria:**
- [ ] Signup → login → session cookie; users/sessions rows Tier D.
- [ ] `market.snapshot(symbol=NVDA)` returns from cache; UI renders price + sparkline + one-line sector.
- [ ] Stale-while-revalidate: UI renders cached immediately; background AdapterQueue job refreshes.
- [ ] AdapterQueue dedupe collapses two concurrent NVDA snapshot calls into one yfinance fetch.
- [ ] Playwright + Playwright-contract: shell single-page, active-symbol rehydration works, ADR-0007 footer present, no imperative-trade-verb in any string.
- [ ] Primary-Rule lint test runs and passes (stoplist).
**Blocked by:** None.
## Slice 2 — auth-2fa-and-social-oauth
**What to build:** Add TOTP 2FA + social OAuth (GitHub/Google) to slice 1; session includes `complexity` default beginner; refresh-token flow. Adds `two_factor` table + `oauth_identities`.
**Acceptance criteria:**
- [ ] 2FA enrollment + login works; backup codes generated.
- [ ] Social OAuth sign-in / link existing account.
- [ ] Session carries complexity; UI reflects beginner defaults.
**Blocked by:** Slice 1.
## Slice 3 — onboarding-wizard
**What to build:** First-login wizard: complexity pick, risk tolerance, drawdown-tolerance plain-English Q (gentle-halt explained), starter watchlist (IREN, CIFR, ASST, SLNH, BKKT, NUAI, NVDA, BTC, SATA) with one-line reasons + ticker-kind, optional portfolio CSV/manual. Writes Tier C watchlist + portfolio. Explicit ADR-0007 statement during onboarding.
**Acceptance criteria:**
- [ ] Wizard completes → user has complexity, risk tolerance, first watchlist (default set with ticker-kind).
- [ ] Crypto symbols flagged "limited research module"; SEC-derived rows gated.
- [ ] Onboarding explicitly states "educational tool, not financial advice".
**Blocked by:** Slice 2.
## Slice 4 — yfinance-backfill-permanent-ohlcv
**What to build:** On first symbol-track (Slice 3 starter watchlist) trigger `history(period="max")`; write permanent daily OHLCV with `adj_close` + `price_adjustments` for splits/dividends; this enables backtests. Extend yfinance SourceAdapter kinds; staleness = daily locked end-of-day.
**Acceptance criteria:**
- [ ] First track of NVDA backfills years of daily candles; rerun is a no-op (stale only checks for NEW).
- [ ] `adj_close` correct; split/dividend in `price_adjustments`; raw toggle available.
- [ ] Storage only Tier A shared; refcount in `symbol_demand` protects while user tracks.
**Blocked by:** Slice 3.
## Slice 5 — chart-lab-panel (M2)
**What to build:** M2 panel — multi-timeframe candles + volume + indicator toggles (EMA 9/21/50/200, RSI, relative volume) reading permanent OHLCV from cache. Per-indicator one-line lesson tooltip (P7 G2). No trade signals; every chart string passes Primary-Rule lint.
**Acceptance criteria:**
- [ ] 1D/1W read from cache; intraday opt-in.
- [ ] EMA200 tooltip gloss present; no buy/sell arrows; relative-volume "above/below typical" not "bullish/bearish".
- [ ] P7 + Primary-Rule lint pass.
**Blocked by:** Slice 4.
## Slice 6 — sec-edgar-adapter-and-filings-panel (M6)
**What to build:** SEC EDGAR SourceAdapter (filings_index, full_text_search, primary_doc, company_facts, 13f_holdings, form4_tx, 13d/13g, filer_cik_meta SIC). M6 filings panel with summaries + materiality 8-K heuristics. ETAG/If-Modified-Since; immutable cache forever. LLM `filing_summary` via FakeLLM fixture in tests.
**Acceptance criteria:**
- [ ] Fetch a 10-K + 8-K with UA + 8 req/sec; 304 re-check is a no-op.
- [ ] Filter by form type/date; "Summarize" renders cached summary; materiality tags present.
- [ ] Filer CIK/SIC fetched once, cached; class-inference ready for next slice.
**Blocked by:** Slice 1 (schema seam).
## Slice 7 — institution-flow-engine (M4 view) + insider-stream (M5)
**What to build:** InstitutionFlowEngine deep module behind M4 per-symbol view (5 holder classes via CIK/SIC; 13F diff; buy-zone estimate stamped "estimated"). Form 4 adapter paths for M5 Insider Activity Stream (Informed Buy/Sell/Routine via 10b5-1). Plotted on quarterly price strip with citation chips.
**Acceptance criteria:**
- [ ] Each owner class has one-line plain-English meaning; buy-zone estimate always stamped "estimated".
- [ ] Form 4 informed events distinct from routine; Routine hidden by default for beginners.
- [ ] Class via CIK SIC metadata, not name heuristics.
**Blocked by:** Slice 6.
## Slice 8 — institutional-dashboard-rollup (M4 dashboard)
**What to build:** M4 dashboard rollup across watchlist + portfolio; compact grid (Symbol / Net Active Conviction Δ / insider recency / class-roll flag / alert); sortable + filterable; one-paragraph LLM `dashboard_rollup` summary (always on, ADR-0005 voice, ADR-0007 footer).
**Acceptance criteria:**
- [ ] Rollup reads across owned watchlists+portfolio; grid sortable; LLM rollup summary present + cited.
- [ ] Summary passes Primary-Rule lint (no "follow this flow").
**Blocked by:** Slice 7.
## Slice 9 — sector-rotation (M7)
**What to build:** RotationDetector deep module: RS-breadth thrust + cross-sectional rank; incipient signal detection daily; γ two-stage resolution (price ~4wk + institutional at quarter-end); rotation phase labels + confidence; retention of signal history with real/false labeling.
**Acceptance criteria:**
- [ ] Heatmap (RS-ratio + rel-volume) + phase labels; signal-history table shows resolution timestamps; false-alarm rate visible per signal type.
- [ ] γ resolution labels real vs false; educational framing "capital appears to be moving".
**Blocked by:** Slice 4 (price history), Slice 7 (institutional).
## Slice 10 — watchlist-portfolio-shell-panels (M9 + M10 minimal)
**What to build:** M9 multiple watchlists (add/import/drag-reorder) with compact mini-overviews. M10 minimal portfolio (holdings, P/L) + journal entry collects Two-Axis Model: fundamental thesis WHY + invalidation criteria + technical entry WHEN + Confluence Rack (SlotLibrary default 4-slot beginner Rack).ApiKey: no TradePlan accepted without stop + risk% + thesis + invalidation criteria (server-side block). A_STAR/Strategy authoring disabled (not unlocked yet).
**Acceptance criteria:**
- [ ] Watchlists CRUD + ticker-kind gating; portfolio CRUD.
- [ ] Journal TradePlan requires stop + risk + thesis-invalidation; server rejects otherwise with helpful error.
- [ ] Confluence Rack default 4-slot beginner; redundancy-awareness tags duplicate signals.
**Blocked by:** Slice 3 (onboarding), Slice 4.
## Slice 11 — sizing-engine-and-conviction-unlock (deep module behind M10/M16)
**What to build:** SizingEngine 4-layer sizing (stop / ATR / conviction-tier / correlation-cluster + macro gate); Conviction Tier unlock gate (20B→A, 10A→A_STAR) reading per-tier win-rate from journal; Two-Axis matrix enforced pre-create in UI AND server-side (High conviction × Bad entry = WAIT; override-with-written-reason). `sizing_explain` LLM feature. SizingEngine pure/cache-deterministic.
**Acceptance criteria:**
- [ ] Sizing computed from plan + account + portfolio + regime; A_STAR blocked until unlock met.
- [ ] Override-with-written-reason recorded; matrix enforced both sides.
- [ ] LLM "if your plan is X, the math implies ~Y shares" framing (Primary-Rule).
**Blocked by:** Slice 10.
## Slice 12 — strategy-lab-and-backtest (M16)
**What to build:** Author + parameterize Strategy bundles {Regime gate, Setup, Risk Policy, Exit Strategy}; BacktestEngine.run/evaluateLatest against permanent OHLCV. Symbol-locked at base unless Conviction Tier unlocks Strategy authoring (slice 11). Exit reasons: TA-stop/thesis-broken/target-hit. Sample-size caveat in UI.
**Acceptance criteria:**
- [ ] Strategy author gated by unlock; backtest runs cache-only; exit-reasons labeled.
- [ ] Equity curve annotated with reasons; no "155% return!" hype highlight (P7).
- [ ] "this Strategy would have behaved" framing (Primary-Rule).
**Blocked by:** Slice 11.
## Slice 13 — universe-evaluator + filter-screener (M15a) + strategy-screener (M15b)
**What to build:** UniverseEvaluator deep module (one engine, two predicates: compiled filter expression OR Strategy entry conditions). M15a filter screener over tiered universe (watchlist → broader by sector). M15b strategy screener delegates to BacktestEngine.evaluateLatest. One-tap "open in workbench". Saved filter sets per-user (Tier C).
**Acceptance criteria:**
- [ ] Filter screener runs instantly on watchlist universe; broader-scan gated with cost/time note.
- [ ] Strategy screener outputs conviction-strength + conditions-fired; one-tap loads M1.
- [ ] "discovery for learning" framing + ADR-0007 footer.
**Blocked by:** Slice 12.
## Slice 14 — sector-confirmation-via-screener cross-link
**What to build:** Wire screener + rotation: "show symbols in the rotated-into sector matching my Strategy". Educational framing only — NOT a ready-made buy list.
**Blocked by:** Slice 9, 13.
## Slice 15 — options-adapters-and-options-dd-panel (M3)
**What to build:** yfinance options_chain kind; M3 read-only Options Due Diligence panel — IV rank/percentile, greeks, OI walls, max-pain; defined-risk stamp; undefined-risk shaded with "advanced only". Feeds M17 in slice 19; never directional options.
**Acceptance criteria:**
- [ ] Options data cached 15min; IV-rank bar teaches the mechanic (not hype gauge).
- [ ] Default no directional options; no "buy this call".
**Blocked by:** Slice 1 (adapter queue).
## Slice 16 — x-cookie-adapter-and-sentiment-feed (M8)
**What to build:** X cookie SourceAdapter (cashtag_search + trusted-account timeline) at 1 req/3s; cookie-expiry → FAILED + source-degraded UI. Reddit PRAW adapter. M8 sentiment feed with per-user trusted accounts + post_summary LLM. Attribution preserved; "crowds aren't edge" caveat.
**Acceptance criteria:**
- [ ] X + Reddit threads cached 7d rolling; trusted accounts per-user.
- [ ] Cookie expiry alerts operator; UI shows cached-only.
- [ ] "Crowd sentiment is not edge" caveat visible; no "buy because Twitter is bullish".
**Blocked by:** Slice 1.
## Slice 17 — alerts-v1 (AlertEngine hybrid)
**What to build:** AlertEngine hybrid (event-driven for cheap Form4/13DA/quote-stale; poll for thesis-monitor). Alert types: informed_buy/sell, new_13da, rotation_incipient, regime_shift, conviction_unlock, thesis_broken/weakening, cluster_breach, drawdown_halt, asymmetry_warning. SSE push. Dedupe per filing.
**Acceptance criteria:**
- [ ] Informed-buy fires once per filing (not every tick).
- [ ] Alert text "something changed" not "action needed" (Primary-Rule).
**Blocked by:** Slice 7, 11, 9, 16.
## Slice 18 — risk-engine-and-risk-posture (M20 + halt circuit breaker)
**What to build:** RiskEngine aggregator → RiskPosture + recommendedActions (reworded considerations per ADR-0007). Gentle halt circuit breaker: MaxDrawdownTolerance breach → `halt_new_entries` 24h + `consider_reducing_position` consideration; existing positions continue; HaltedError on `journal.trade.create` during cooldown. M20 posture surface; prominence on M19 (S20 read-only mobile from same API).
**Acceptance criteria:**
- [ ] Gentle halt blocks new entries 24h; existing continue; consideration reworded (ADR-0007).
- [ ] Asymmetry < 1 → warning; cluster > cap → consider_rebalancing_cluster.
- [ ] Every recommended-action has "trade-off to think through" frame + ADR-0007 footer.
**Blocked by:** Slice 11.
## Slice 19 — options-convexity-sleeve (M17)
**What to build:** M17 5-state unlock (Off → Covered Income → Cash-Secured Entry → Insurance Sleeve → LEAPS Conviction), defined-risk-only; naked永远 blocked; IV-Regime Gate; requires core position or articulated thesis; default OFF. Payoff diagrams teach the convex mechanic (P7 G1/G4). Reads M3 (slice 15).
**Acceptance criteria:**
- [ ] 5-state unlock with demonstrated-understanding step before each elevation.
- [ ] Max-loss/breakeven/convex-shape labeled; no P&L celebration.
- [ ] "insurance / cheaper entry / defined leverage" frame only; ADR-0007 footer.
**Blocked by:** Slice 15, 18.
## Slice 20 — macro-module (M18)
**What to build:** FRED adapter + economic-calendar adapter (Ethercalc fixed safely); M18a calendar, M18b regime classifier, M18c Portfolio-Impact Commentary (LLM Druckenmiller lens, 2-horizon: short-term reaction risk with sample-size + disclaimer; long-term structural), M18d regime history. Never a macro-trade recommendation (Alfred caution).
**Acceptance criteria:**
- [ ] Regime history lane teaches regime-shift mechanic; commentary samples disclosed.
- [ ] No macro-trade recommendations (Primary-Rule).
**Blocked by:** Slice 18 (portfolio link + regime).
## Slice 21 — thesis-monitoring-l1 (timeline + cover alerts wired)
**What to build:** L1 thesis monitor via local-only LLM (ADR-0006) cross-refs stated invalidation criteria against events (filing/insider/sentiment) → intact/weakening/broken; wiring into AlertEngine (thesis_broken/weakening). Feeds "consider exiting if thesis broken" — never silent hold (RiskEngine rule).
**Blocked by:** Slice 17, 18.
## Slice 22 — reports-research-note (M11 HTML v1)
**What to build:** ReportRunner HTML research-note v1 (inline SVG charts, P7, Analyst Voice, ADR-0007 footer). Scopes: symbol/watchlist/portfolio/rotation/sizing_year/risk_posture (cache-only reconstruction). Markdown + CSV/JSON deferred.
**Acceptance criteria:**
- [ ] HTML report self-contained, opens in browser; browser print → PDF works.
- [ ] recommendedActions rendered as considerations+questions; ADR-0007 footer present.
- [ ] Cross-owner download → NotOwnerError; deterministic given cache.
**Blocked by:** Slice 18.
## Slice 23 — derisking-strategy-library (behind M10)
**What to build:** Derisking Library 6 (scale-out at targets / stop-trail-up EMA21-50 / thesis-based partial / option-protected collar / regime-cut / correlation-driven). `derisk_suggestion` LLM (Alfred framing — winners have flexibility; losers only cut, never average down). Options-protected hold depends on slice 19.
**Blocked by:** Slice 19, 21.
## Slice 24 — mobile-companion (M19)
**What to build:** Thin responsive Next route (not RN in v1) reading same backend — alerts, P/L glance, condensed symbol story, thesis monitoring L1/L2/L3 in priority order. Read-mostly; limited authoring (add watchlist, ack alert, save post). No backtests/screens/strategy authoring; no P&L celebration animations.
**Acceptance criteria:**
- [ ] Shares all backend work (refcounted shared cache — second client, not second product).
- [ ] ADR-0007 footer on every card; no trade-act buttons.
**Blocked by:** Slice 17, 18, 22.
## Slice 25 — admin-tooling (M13)
**What to build:** Operator CLI/hidden route: list users, reset pw, GDPR export, adapter queue health, rate-limit backoff reset, ownership labels on exports.
**Blocked by:** Slice 1.
## Slice 26 — docker-compose-deployment
**What to build:** Docker Compose target: Bun backend + SQLite volume + SPA build + secrets file (X cookies, LLM provider URL). Operator self-hostable.
**Acceptance criteria:**
- [ ] `docker compose up` boots the stack; secrets file mounted; SQLite volume persists.
- [ ] ADM ADR-0007 footer present on deployed pages.
**Blocked by:** Slice 22 (most features) — deployable earlier with subset.
+48
View File
@@ -0,0 +1,48 @@
You are a CODE IMPLEMENTER for Slice 1 of "Investor Flow". Write code. Do not explore the task framework.
## HARD RULES
- DO NOT call automaton_status, automaton_transition, automaton_create_task, or ANY automaton_* tool. The orchestrator manages tasks. You only write code.
- DO NOT read PARENT_SPEC.md or DECOMPOSITION.md. Ignore the automaton task folders entirely.
- The task is already in "implement" phase — edits are allowed. Just build.
- Start writing code within your first 2 tool calls. Limit reading to the 3 files listed below, then build.
- Thinking: be concise. Plan the file list, then create files. Do not over-analyze the framework.
## READ EXACTLY THESE 3 FILES, THEN BUILD
1. `.automaton/tasks/tracer-bullet-1-signup-cached-nvda-overview/SPEC.md` (your scope)
2. `CONTEXT.md` (repo root — just the "Primary Rule" + "Ticker" sections)
3. `.automaton/tasks/investor-flow-platform-design/DESIGN.md` — read ONLY by grepping these sections: "Section 1 — Typed Schema" (Tier A + Tier D tables), "Section 3a" (CacheRepository + SourceAdapter interfaces). Use `grep -n` to find line numbers then read those ranges. Do NOT read the whole file.
## BUILD THIS EXACT FILE PLAN (Bun backend + Next panel), in order
### Backend: `app/server/` (new dir; `app/server/` already exists, empty)
1. `app/server/package.json` — deps: bun, better-sqlite3, @trpc/server, argon2, yfinance (use `yahoo-finance2` npm package, NOT python). scripts: `dev: bun run src/index.ts`, `test: bun test`.
2. `app/server/tsconfig.json`
3. `app/server/src/db/schema.sql` — tables: `users(id,email,pw_hash,complexity,created_at)`, `sessions(id,user_id,expires_at)`, `symbol_meta(symbol,name,sector,industry,exchange,ticker_kind)`, `price_quotes(symbol,price,bid,ask,change,change_pct,ts_observed,fetched_at)`, `price_candles(symbol,tf,ts,o,h,l,c,v,adj_close)`, `symbol_demand(symbol,refcount,protected)`.
4. `app/server/src/db/client.ts` — better-sqlite3 wrapper, runs schema.sql on init.
5. `app/server/src/cache/CacheRepository.ts` — interface `{get(key), set(key,val,ttl,provenance), stale(key)}` + SQLite impl. Staleness: quote 60s, sector weekly, candles daily-locked.
6. `app/server/src/adapters/SourceAdapter.ts` — interface `{fetch(symbol, kind, opts): Promise<CacheRecord[]>}` (the deep-module interface from DESIGN §3a).
7. `app/server/src/adapters/YFinanceAdapter.ts` — implements SourceAdapter using `yahoo-finance2`. Kinds: `quote`, `price_history` (daily, last 30 days for sparkline), `info/sector`.
8. `app/server/src/queue/AdapterQueue.ts` — token-bucket (1 req/s, burst 2), dedupe by `source|kind|symbol|paramsHash`, exponential backoff on 429 (2,4,8,16,60s, 5 tries→FAILED).
9. `app/server/src/trpc/router.ts` — procedures: `auth.signup({email,password})`, `auth.login({email,password})→session`, `market.snapshot({symbol})→{quote,symbolMeta,recentCandles}` (reads cache, enqueues refresh if stale).
10. `app/server/src/trpc/context.ts` — session guard.
11. `app/server/src/index.ts` — Bun.serve on :3001, mounts tRPC.
12. `app/server/src/__tests__/CacheRepository.test.ts` — in-memory SQLite, staleness asserts.
13. `app/server/src/__tests__/AdapterQueue.test.ts` — FakeSourceAdapter, dedupe collapses 2 calls→1, 429 backoff.
14. `app/server/src/__tests__/router.test.ts` — signup/login/market.snapshot integration with FakeSourceAdapter.
15. `app/server/src/__tests__/primary-rule-lint.test.ts` — grep curated UI strings for stoplist ["buy","sell","you should","add to your","rotate into","action needed"] (allow "buy-zone estimate"); FAIL on imperative trade directive.
### Frontend: reuse existing `app/` Next project
16. `app/src/lib/trpc.ts` — tRPC client to :3001.
17. `app/src/stores/active-symbol-store.ts` — Zustand store, default symbol "NVDA".
18. `app/src/components/OverviewPanel.tsx` — M1: name + sector + complexity badge (beginner) + price + day-change sparkline (Recharts, already installed) + one-line "why this matters" placeholder + ADR-0007 footer.
19. `app/src/app/page.tsx` — rewrite to render OverviewPanel bound to active-symbol store + a minimal login/signup form (calls auth.signup/login). Single-page, no extra routes.
20. `app/src/app/globals.css` — keep existing; ensure footer style.
## PRIMARY RULE (ADR-0007) — the lint test MUST pass
No UI string says "buy/sell/hold this". Footer text (in OverviewPanel): "Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions."
## WHEN DONE
Run `cd app/server && bun test`. If all green, run `cd app && npx tsc --noEmit` for the frontend. Print a summary: files created, test results, which acceptance criteria met. Then STOP.
## SCOPE GUARDRAILS — DO NOT BUILD
No 2FA/OAuth, no onboarding, no full max-history backfill (only last 30 days for sparkline), no other panels, no LLM calls, no Docker.
+44
View File
@@ -0,0 +1,44 @@
You are implementing **Slice 1 (tracer bullet)** of the Investor Flow project. You are an implementer agent operating with read/bash/edit/write tools. The orchestrator will review your diff afterward.
## READ THESE FIRST (in this order)
1. `.automaton/tasks/tracer-bullet-1-signup-cached-nvda-overview/SPEC.md` — YOUR EXACT SCOPE. Build only what it lists.
2. `CONTEXT.md` (repo root) — domain glossary + Primary Rule (Education, not investment advice).
3. `docs/adr/0001-local-first-multi-tenant.md`, `docs/adr/0004-shared-cache-vs-per-user-fetch.md`, `docs/adr/0007-education-not-investment-advice.md` — the load-bearing ADRs for this slice.
4. `.automaton/tasks/investor-flow-platform-design/DESIGN.md` — read **Section 1 (Typed Schema, Tier A + Tier D only)**, **Section 2 (API Surface, tRPC + auth seam + market.snapshot)**, **Section 3a (CacheRepository + SourceAdapter interfaces — match these EXACTLY)**, **Section 7 (test-seam map, FakeSourceAdapter, Primary-Rule lint)**.
Do NOT read all of DESIGN.md — only the sections above. It is 1379 lines; the rest is for later slices.
## WHAT TO BUILD (from slice SPEC — do not over-build)
A Bun + SQLite backend + a single-page Next.js panel where a user signs up (email+password, argon2/bcrypt, NO 2FA), logs in, and sees a cached NVDA overview hydrated by one yfinance adapter behind CacheRepository + AdapterQueue, served via tRPC.
Concretely, establish the skeleton that every later slice extends:
- SQLite schema: `users`, `sessions`, `symbol_meta`, `price_quotes`, `price_candles` (daily, permanent, with `adj_close`), `symbol_demand` (refcount). Match DESIGN.md §1 Tier A + Tier D.
- yfinance SourceAdapter: kinds `quote`, `price_history` (daily), `info/sector`. Use the `yfinance` python package via a thin subprocess/HTTP bridge OR a JS yfinance client — pick the simpler one that works. No options/holders/fundamentals.
- CacheRepository: `get/set/stale` with staleness (quote 60s market-hours / 15m after-hours; price_history daily locked EOD; sector weekly). Stale-while-revalidate (return cached immediately, enqueue background refresh).
- AdapterQueue: token-bucket 1 req/sec sustained burst 2/sec; dedupe same `source|kind|symbol|paramsHash`; exponential backoff on 429 (2,4,8,16,60s, 5 attempts → FAILED).
- tRPC: `auth.signup`, `auth.login`, `market.snapshot(symbol)`.
- SPA shell (reuse existing `app/` Next project, do NOT create a new one): single-page, active-symbol Zustand signal (NVDA hardcoded), M1 Symbol Overview panel (name + sector + complexity badge default beginner + price + day-change sparkline + one-line "why this matters" placeholder + ADR-0007 footer).
- Test infra: FakeSourceAdapter, in-memory SQLite fixture, tRPC integration test, Playwright contract test.
- Primary-Rule lint test: grep curated UI strings for stoplist ["buy","sell","you should","add to your","rotate into","action needed"] outside approved noun phrases ("buy-zone estimate" allowed). FAIL on imperative trade directive.
## SCOPE GUARDRAILS — DO NOT BUILD
2FA/OAuth (slice 2) · onboarding wizard (slice 3) · full `period="max"` backfill (slice 4 — only fetch latest quote + recent daily candles for the sparkline here) · any other panel M2–M20 · LLM Gateway/ornith (M1 "why this matters" is a placeholder STRING, not an LLM call) · Docker Compose (slice 26).
## ENGINEERING DISCIPLINE (from installed skills — follow these)
- `tdd`: vertical slices, red-green-refactor. One test → one impl → repeat. Do NOT write all tests then all impl.
- `codebase-design`: CacheRepository and SourceAdapter are DEEP MODULES — small interface (match DESIGN.md §3a exactly), large implementation hidden behind it. The interface IS the test surface; tests cross the same seam as callers.
- Tests use FakeSourceAdapter + in-memory SQLite. NO network calls in tests.
## PRIMARY RULE (ADR-0007) — non-negotiable
No user-facing string says "buy/sell/hold this." ADR-0007 footer on the page: *"Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions."* Primary-Rule lint test must pass.
## HOW TO WORK
1. Read the files above.
2. Check the existing `app/` structure (Next 16, React 19, Tailwind v4, Zustand already present). Reuse it.
3. Scaffold the Bun backend in a new `app/server/` directory (or `backend/` at repo root — pick one and be consistent). Install deps with bun.
4. Implement TDD: write the schema + a failing test, make it pass; write CacheRepository interface + failing test, pass; etc.
5. Run the test suite. Make it green. The Primary-Rule lint test must pass.
6. When acceptance criteria in the slice SPEC are all green, STOP. Do not start slice 2.
## OUTPUT
Write the code to disk using your edit/write tools. When done, print a concise summary: files created/modified, test command + result, and which acceptance criteria are met. The orchestrator (me) will review the diff.
+242
View File
@@ -0,0 +1,242 @@
# Investor Flow — Domain Glossary
This file is the ubiquitous language for the Investor Flow project. It is a glossary, not a spec. Updated inline as terms are resolved during design (per the `domain-modeling` skill).
## Product
**Investor Flow** — A beginner-first, multi-tenant investment research terminal for retail investors with some stock experience, focused on conviction-based investing (not trading). Local-first: SPA + Bun/SQLite backend, Docker Compose deployment. Voice: convex in process, soft in presentation.
**Primary Rule — Education, not investment advice.** Investor Flow is an educational research terminal, not an investment adviser. It teaches *how a disciplined investor reasons* about a position; it never says "buy/sell/hold this." Every recommendation is a **consideration + a question**. RiskEngine `recommendedActions` are reworded (cut_to_cash → consider_reducing_position; trim_cluster → consider_rebalancing_cluster). SizingEngine outputs *math*, not instructions. Journal asks "what's your reasoning?" Alerts say "something changed," not "action needed." Every report closes with: *"Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions."* Legal posture: educational publisher. Formalized in ADR-0007. Every LLM prompt template and UI string reviewed against this rule ("Primary Rule lint"). When this conflicts with another principle, this wins.
**Analyst Voice** — The single house voice for every LLM Signal Summary, explainer, and alert. 70/25 blend of Mike Alfred (Alpine Fox LP — concentrated value conviction, ownership posture, plain-spoken directness) and Stanley Druckenmiller (macro-regime adaptation, asymmetric convexity). Rules: process over prediction; concentration posture; macro + company twin-lens; honest about uncertainty; P6 plain English; cite every claim to a cached source; confident when conviction exists, silent when it doesn't. Formalized in ADR-0005.
**P6 — Accessible-but-authored voice.** Core UX principle. Every user-facing string reads as plain English to a non-finance reader while staying professional. No orphan jargon (every term glossed in-screen); no baby talk; no unexplained acronyms. Complexity level adapts depth, not voice.
## Strategy & Sizing
**Strategy** — A named bundle of { Regime gate, Setup, Risk Policy }, reusable across symbols. A TradePlan is an instance of a Strategy applied to a symbol at a time.
**Regime** — A classification of market state (trending-up / trending-down / range-bound) over an index/timeframe, used to gate trading posture and sizing.
**Setup** — A parameterized, repeatable entry/exit checklist applied to a symbol situation.
**Risk Policy** — Sizing + stop-width + defined-risk rules applied when a setup fires.
**Screener** — A tool that evaluates a universe of symbols against one or more Strategies' entry conditions and outputs matches. (A screener run is a read-only backtest at "now".)
**Backtest** — Running a Strategy against cached historical OHLCV to compute performance. Cache-only; no live calls. Same engine as Screener.
**Position Sizing** — The function producing share quantity given a Strategy, risk%, stop, account, live portfolio, and regime. Multi-layered (Layers 0–4).
**Risk Per Trade** — Dollars an account is willing to lose if a plan's stop is hit, before any size is computed. Default 1% for beginners.
**Conviction Tier** — A_STAR / A / B / C — the user-graded quality of a setup; multiplies the base risk fraction (×3 / ×2 / ×1 / ×0.5). Locked at lower tiers until per-tier win-rate statistics justify unlocking.
**Sizing Unlock** — A workflow state: complexity-tier elevation earned by accumulating profitable trades at a sub-tier (20 profitable B → unlock A; 10 profitable A → unlock A_STAR). Tunable per-user.
**Macro Regime Gate** — When regime trending-down = ×0.5 size; ranging = no A_STAR; trending-up = normal + A_STAR unlocked. Default: auto-cap with one-tap override requiring a written reason.
**Correlation Cluster** — A group of holdings sharing a single macro/factor driver (e.g., all BTC miners). Aggregated exposure capped; beginners get hard caps, intermediates get warnings.
## Institutional & Insider
**Quarter** (institutional module) — The reported calendar quarter a 13F snapshot is *as of*. Buy-zone/sell-zone estimates reconstruct institutional transaction likelihood within that quarter from the snapshot diff + volume-weighted price action.
**Institutional Footprint** — Per-symbol combined ownership picture from all four SEC forms (13F, 13D, 13G, Form 4), with every owner row classified by holder type.
**Holder Class** — One of: `Passive Index`, `Activist`, `Active Conviction`, `Market-maker/Hedger`, `Insider`. Inferred from which SEC form(s) filed + CIK entity metadata (SIC codes).
**Net Active Conviction Δ** — Per-symbol QoQ share change summed across Active Conviction + Activist classes only. Excludes passive index and MM/hedger noise. The ranked signal on the dashboard.
**Holder Snapshot Diff** — Quarter-over-quarter Δ per institution per symbol, derived from consecutive 13F filings.
**Insider Transaction** — A single Form 4 transaction by an officer, director, or 10%+ holder. Classified: Informed Buy / Informed Sell / Routine.
**Informed Buy** — Open-market buy (code P) NOT under a Rule 10b5-1 plan. Highest-signal insider bull.
**Informed Sell** — Open-market sell (code S) NOT under 10b5-1. Negative signal; alert by default.
**Routine Insider Transaction** — Any Form 4 transaction under 10b5-1, OR an option exercise (M), grant (A), or vesting event. Pre-scheduled/compensation-driven; low signal; hidden by default for beginners.
**Buy-Zone Estimate** — Volume-weighted price band during a quarter in which an institution *likely* accumulated/reduced. An estimate, always tagged "estimated" in the UI.
**Insider Activity Stream** — Real-time per-symbol ledger of Form 4 filings, classified, with Informed events surfaced first and alertable. Own module (M5), separate from quarterly snapshot (M4).
## Sector Rotation
**Sector Rotation** — Movement of capital between market sectors. The module surfaces both realized (RS-rank reshuffle over N weeks) and incipient (early RS-slope turn + breadth thrust before price moves) rotation.
**Rotation Phase** — A label on detected rotation: Accumulation → Expansion → Distribution → Markdown, with a confidence score.
**Rotation Signal History** — Every incipient rotation signal is logged and later resolved as Real or False-alarm. Two-stage resolution (γ): price follow-through confirms first (within N=4 weeks, deterministic); institutional flow confirms later (quarter-end, durable). Enables "which signal types were reliable" learning.
## Ticker
**Ticker Kind** — `equity` | `crypto` | `etf` | `index`. Gates which modules apply (e.g., crypto excluded from SEC/13F/insider modules). BTC kept for price/sentiment only.
## Screener (two modules)
**Filter Screener (M15a)** — A TradingView/Finviz-style screener: user writes ad-hoc filter expressions (descriptive, technical, fundamental, events, ownership, sentiment) over a universe; outputs matching symbols with "why matched." Beginner-immediate. No Strategy required. Saved filter sets are per-user (Tier C). Not backtested. Universe: tiered (watchlist first, opt-in broader scan scoped by sector).
**Strategy Screener (M15b)** — A screener that evaluates a chosen Strategy bundle's entry conditions across a universe; outputs symbols where the Strategy fires, with conviction-strength (how many entry conditions true). Backtestable. Requires an authored Strategy (gated by Conviction Tier unlocks). Output: symbol + which conditions fired + one-tap into Symbol Overview workbench.
**UniverseEvaluator** — Shared deep module iterating a symbol universe and applying a pure predicate (a compiled filter expression OR a Strategy's entry conditions) against cached data. Powers both M15a (filter predicate) and M15b (Strategy predicate). One interface, two callers.
Screener output policy (both modules): symbol + "why matched" + one-tap "open in workbench" (drop symbol into Symbol Overview). The screener shortens time-to-conviction; it is never the final answer.
## Options Convexity Sleeve (M17)
**Options Convexity Sleeve (M17)** — A portfolio module applying a small defined-risk options overlay to a core holding — for income (covered calls), cheaper entry (cash-secured puts), downside insurance (protective puts/collars), or leveraged thesis (long LEAPS). Never standalone directional. (Research-grounded: Spitznagel/Universa tail-hedging, Pabrai cash-secured entry, Ackman rate-hedge overlay, Druckenmiller cheap-convexity, Buffett index-put seller.)
**Sleeve Risk Budget** — Per-thesis combined budget for stock + options risk (e.g. 1–5% of portfolio). SizingEngine enforces the combined ceiling so options can't quietly swell.
**Convexity Posture** — A user's options advancement state: `Off → Covered Income → Cash-Secured Entry → Insurance Sleeve → LEAPS Conviction`. Unlocks progressively by Sizing Unlock (mirrors Conviction Tier). Default `Off` (Alfred: "I don't think most people should use options tbh").
**Defined-Risk Only** — Hard module constraint: strategies with unbounded loss (naked short calls, naked short straddles, undefined-risk structures) are physically blocked. Beginner guardrail; never disabled for beginners.
**Tail Convexity** — A small portfolio sleeve (~1–3%/yr) of long-dated deep-OTM puts on broad indices OR a correlation cluster (Spitznagel/Universa model). Pays small premium most months, asymmetric payout in tail events — *insurance*, not speculation.
**IV Regime Gate (within M17)** — Modulates allowed overlays by IV Percentile: covered calls when IV high (juicy premium); protective puts / LEAPS when IV low (cheap insurance). Never buy convexity when it's expensive.
## Macro Module (M18)
**Macro Module (M18)** — Fourth pillar module: calendar of high-impact macro events (M18a), current-regime classifier (M18b), portfolio-impact commentary (M18c), and regime history (M18d). The Druckenmiller 25% lens given dedicated surface. Reads macro + connects to portfolio; never recommends a macro trade (the Alfred caution extends to macro-trading). Leaves action to Conviction Tier + Convexity Posture gates.
**Macro Regime** — A label classified by M18b (trending-up / trending-down / range-bound / structurally-shifting) from yield curve, inflation, rate path, liquidity, breadth. Consumed by the Macro Regime Gate (SizingEngine Layer 4). M18b is the classifier the gate depends on.
**Portfolio Impact Commentary** — LLM-generated (Druckenmiller lens), two-horizon: short-term reaction risk (per-event, per-correlation-cluster, with historical averages + sample size + disclaimer, never a forecast) and long-term structural read (regime-shift framing). Cites M18 regime + the user's actual holdings. Always on (matches Analyst Voice "always on" decision).
**Regime History** — Timeline of regime classifications over time; cross-references Rotation Signal History. Pedagogy for "did we detect the shift correctly" — beginner learns which classifier calls were early vs whipsawed.
## LLM Data Provenance (ADR-0006)
**ornith** — The default LLM provider for Investor Flow: a remote-hosted, operator-owned, OpenAI-compatible REST endpoint. Configured `is_local=true` in `llm_providers` so it serves both public and sensitive features (ADR-0006 Data-Classification Gate). Env vars `ORNITH_LLM_URL` + `ORNITH_LLM_API_KEY` injected via Docker Compose secrets. Formalized in ADR-0008. The provider itself is pluggable; `ornith` is the v1 default, not a hard dependency.
**LLM Data Provenance** — Three hard constraints enforcing no-training-on-user-data: (1) Production LLM Gateway defaults to a **local OpenAI-compatible endpoint**; non-local providers require explicit operator override + documented provenance review. (2) **Sensitive user data** (portfolio positions, trade plans, journal, SEC content, sentiment annotations, saved posts, anything tagged ownerId or Tier C) is **never routed through external LLM providers** by code-level data-classification gate, not a runtime toggle. (3) **Developer conduct**: never paste live user-data into external AI-assistant prompts during the build; use fixtures/synthetic data only.
**Local-first default** — The Gateway's `baseURL` defaults to a local endpoint (Ollama/vLLM on host or LAN); the only provider config loaded by default. "Local" = `localhost`, `127.0.0.1`, `::1`, or `LOCAL_LLM_SUBNETS` env override.
**Data-Classification Gate** — `LLMGateway.classifyPayload(payload): 'public_safe' | 'sensitive'` runs before any provider dispatch. Sensitive payloads routed to non-local providers throw `SensitiveDataBlockedError` and never leave the host. A code-level guarantee, tested via property tests over a fuzz corpus.
## Ticker-Data Dedupe (Architectural Pattern)
**Content-Addressed Caching** — All fetched market data is cached by a content-addressed key (e.g., `yfinance:quote:IREN`). Two users fetching the same symbol collapse to the same row; duplicates don't happen by construction. Tier A tables (price/candles/options/filings/13F) have no ownerId and are shared.
**Demand Set** — The set of symbols that currently have ≥1 user tracking them (watchlist ∪ open portfolio holdings). The adapter queue only schedules fetches for symbols in the demand set — zero wasted bandwidth on untracked symbols. Cost scales with the *breadth of the demand set*, not user count (a million users all tracking NVDA = still one NVDA fetch per staleness window).
**Refcount** — Per-symbol demand counter, bumped +1 when a user tracks the symbol, −1 when they untrack. Refcount → 0 halts live/sentiment refresh for that symbol; historical immutable data (backtest OHLCV, SEC filings) stays cached regardless because backtests/filings need it forever.
**Stale-While-Revalidate** — The SPA reads from cache instantly and the backend silently schedules a background refresh if the row is past its staleness window — the user never blocks on a live fetch. Duplicate concurrent cache-misses for the same key collapse to ONE fetch (multi-tenant dedupe per ADR-0004).
## P7 — Visualizations teach mechanics, never decorate outcomes
**P7** — Core UX principle (added alongside P6). Every visualization makes a mechanical relationship more *legible*, never more *exciting*. Visuals can teach or manipulate (Robinhood's confetti SEC fine is the cautionary tale). The four guardrails:
- **G1 — Teach the mechanic, don't decorate the outcome.** A chart showing *where Citadel accumulated within the quarterly price band* teaches; a "+15%!" bouncing number celebrates. Alfred-lean voice explains mechanics, never celebrates results.
- **G2 — Every chart has a one-line "what this tells you."** No naked visualization. Each chart/heatmap/payoff diagram carries a P6 plain-English caption written at design time. If we can't write the lesson, the viz is decoration, not a feature — drop it.
- **G3 — Color-blind / accessibility-safe by default.** Red/green for P/L is dangerous (8% of men). Use **shape + color + label** triads: institutional adds = up-triangles in blue, reduces = down-triangles in amber, text labels always present. Recharts/visx support custom shapes natively.
- **G4 — Annotated data viz over abstract 仪表盘.** The LLM-generated Signal Summary produces **annotated chart overlays** — summary text has chart positions tagged. One annotated story, not 5 detached widgets.
## Anti-gamification rules (locked)
Explicit "do not build" list — written down so the build doesn't accidentally Robinhood itself:
- No trade-animation confetti / win-streak badges / "you did it!" celebrations — Robinhood was SEC-fined for this.
- No 3D / decorative gauges — chart-porn, no mechanic taught.
- No real-time candle-minute flickering — encourages trader-brain, not investor-brain. Daily candle with a steady quote line is enough.
- No outcome-celebration visuals. Sizing/backtest viz shows mechanics (drawdown is more important than win rate), not trophies.
## Canonical visualizations (locked — the curated 10)
Each is curated at design time with its one-line lesson (G2). Not an open-ended viz library.
1. **Annotated Price Chart** — candles + Form 4 insider buy ▲ markers + institutional buy-zone shaded band + macro event markers + key levels overlaid. *Lesson: prices move WITH insiders/institutions/events, never alone.*
2. **Sector Rotation Heatmap** — sectors × weeks grid, color = RS rank, cells pulse on phase transitions. *Lesson: money rotates between sectors; early signs are subtle.*
3. **Rotation Phase Timeline** — horizontal stream of Accumulation→Expansion→Distribution→Markdown markers. *Lesson: rotation is a phase sequence, not an instant.*
4. **Options Payoff Diagram** — today + at-expiry + early curves, max loss/gain shaded, breakeven marker. *Lesson: an option position is a SHAPE not a bet; defined-risk is visibly bounded.*
5. **Institutional Footprint Stacked Bar** — per-symbol holder-% by Holder Class, color-coded (MM/hedger vs Active Conviction visually distinct). *Lesson: not all ownership is conviction.*
6. **QoQ Institutional Δ Chart** — per-filer bars over a price-range overlay at the band they likely transacted at. *Lesson: institutions move over a quarter, on a price band.*
7. **Correlation Cluster Treemap** — your holdings grouped by factor driver; cluster size = exposure $. *Lesson: 5 holdings != 5 bets; factor risk (the 2008 lesson).*
8. **Sizing Decision Tree** — account → risk-per-trade → tier multiplier → position size, animated as you change a Conviction Tier. *Lesson: sizing isn't a guess; it cascades from rules you earned.*
9. **Strategy Backtest Equity Curve + Drawdown overlay** — P/L line with drawdown under it; trade dots are entry+exit. *Lesson: worst pain matters more than win rate.*
10. **Annotated Institutional Event Card** — one chart with text callouts at each event point + the one-paragraph Analyst Voice summary alongside. *Lesson: many data streams → one coherent story. THE beginner viz.*
## M19 — Mobile Monitor (companion surface, not mobile-first)
**Mobile Monitor (M19)** — Separate thin client reading the same Bun backend + SQLite cache + adapter queue + LLM Gateway as the desktop workbench. For job A (passive monitoring: alerts, P/L glance, per-symbol story) NOT job B (active research: backtests, multi-panel cross-reference, screener, strategy authoring). Read-mostly; limited authoring (add to watchlist, ack alert, save post). Native is a later phase; v1 = Next responsive route, not React Native. Built second, after the desktop workbench.
**Two-job split** — Investor Flow serves two jobs at two home surfaces: (A) passive monitoring on the go = mobile phone, M19; (B) active research at a desk = desktop/laptop, M1–M18 workbench. One backend, two co-equal client surfaces. Going mobile-first would force job A's surface on job B (Robinhood's bet); the depth that justifies the product dies.into tabs. Build desktop workbench first; thin mobile companion second shares all backend work (refcounted shared cache means mobile is a second client, not a second product). ~30% more frontend, 0% more backend.
## Two-Axis Investment Model (Fundamental × Technical)
**Fundamental Thesis** — The written WHY of an investment: what to own + invalidation criteria. Tracked over time (`intact` / `weakening` / `broken`). Broken thesis = exit regardless of TA. Process-grounded: Alfred's BKKT entry was 8 months of fundamental thesis-building before deployment.
**Entry Confluence Rack** — The accumulating WHEN: a structured checklist of technical confluences. Each lit = +1; more lit = better entry quality. Generalizes the old journal's `confluenceCount` into a structured, named, trackable rack. A user authors the Rack per-Strategy by pulling from the Confluence Library.
**Two-Axis Posture** — Every investment has two independent axes: fundamental conviction (Conviction Tier) × technical entry (Confluence Rack). High conviction + bad entry = WAIT (never "buy like an idiot"). The SizingEngine enforces the posture matrix as a hard guardrail. Override requires one-tap + written reason (mirrors Macro Regime Gate).
**Entry Confluence Rack (4-factor default)**:
- **A. Trend alignment** — price > EMA200, EMA21 > EMA50, higher-highs/lows. +1 when all 3 hold.
- **B. Pullback maturity** — recent low near EMA21 or a key level + volume dryup on the dip. +1 when both.
- **C. Catalyst proximity** — earnings / 13D/A / insider informed-buy / rotation phase Accumulation→Expansion within ≤30 days. +1.
- **D. Risk-bound coherence** — Stop-loss set at a real level (under support / under EMA50) AND reward-to-risk ≥ 2:1. +1.
**Entry regime labels** (what the user sees, P6 plain English, P7 visual):
- 0–1 lit = **"Bad entry"** (WAIT — app shows which confluences are unlit + what would light them).
- 2 lit = **"Marginal entry"** (plan a small first deployment only).
- 3–4 lit = **"Good entry"** (DEPLOY per Conviction Tier capacity).
**Confluence Slot** — A single authored predicate `lit(symbol, cachedData) → bool`. Pulled from the Confluence Library. Rack = collection of slots. Default Rack = 4 slots (beginner); extendable to 6+ (advanced).
**Confluence Library** — Registry of named parameterizable slot templates: `trendAlignment`, `pullbackToEMA21`, `catalystProximity`, `riskBoundCoherence`, `sma200w` (200-week SMA proximity), `rsImproving`, `breadthThrust`, `insiderInformed30d`, `instNetActivePositive`, `ivPercentileLow`, etc. Long-horizon SMAs (e.g., 200-week) measure multi-year holder cost basis — strong *thesis support*, can also be authored as *entry slots* when price is within X% of the level.
**Redundancy Awareness** — When multiple lit slots measure the same underlying signal (e.g., 21-EMA > 50-EMA AND price > EMA200 AND price > 200-day SMA in a persistent uptrend), the app tags them "redundant — counts as one signal." Analyst Voice explains why; the user keeps/removes at will. Adding indicators doesn't necessarily improve entries; redundancy adds noise.
**Default Rack by complexity** — Beginner: 4-factor. Intermediate: +rotation-phase slot. Advanced: +full authorable Confluence Library (incl. 200-week SMA, VWAP, volume-profile POC, etc.).
**Deployment Schedule** — How capacity (Conviction-Tier-sized) is entered over time/space, gated by Confluence Rack accumulation. Replaces one-shot entry with corridor scale-in (Alfred's May 15-18 four-day deployment at a price corridor). Beginner-default: "deploy 40% at Rack≥3; deploy remaining 60% at Rack≥4 OR price revisits buy-zone / EMA21."
**Thesis Monitoring** (autonomous, 3 layers):
- **L1 — Per-holding**: every relevant event for that symbol cross-references the user's stated invalidation criteria; fires intact/weakening/broken per position.
- **L2 — Portfolio posture**: aggregated stock-level weakening ≥ threshold; correlation cluster over-exposure; regime change.
- **L3 — Macro context**: sector rotation phase flips; macro regime classification updates; market breadth thrust triggers.
Each independently alertable; surfaces on M1 (L1), M10 portfolio dashboard (L2), M18 macro module (L3); M19 mobile shows all three in priority order. Not a forecast engine — never predicts returns; only translates stated invalidation criteria into alerts when matching events arrive. L1 runs via CacheRepository reads + LLM Gateway over local-only provider per ADR-0006 (thesis content = sensitive user data).
## Risk Management (ADR-0007 territory)
**RiskEngine** (deep module) — Pure aggregator reading portfolio + theses (L1/L2/L3) + correlation clusters + macro regime → RiskPosture. Never touches network. Produces: totalRiskUsd/Pct, drawdownUsd/Pct, maxDrawdownTolerance status (healthy/warning/halted), clusterBreaches, regimeRisk (aligned/misaligned/hostile + recommendedCashPct), asymmetryScore, cashReservePct, recommendedActions (cut_to_cash/trim_cluster/halt_new_entries/close_thesis_broken/continue). Surfaces on M10 desktop + M19 mobile.
**M20 Risk Posture** — Single unified risk-picture surface. The "where you see your complete risk picture + what to cut" panel. Curated visualizations under P7 (drawdown gauge is NOT a decorative gauge — teaches the mechanic: drawdown-vs-tolerance-by-style-fit), recommendedActions with cited inputs. Prominent on M19 mobile.
### The 6 risk primitives (locked dictionary)
**Max Drawdown Tolerance** — Per-user $/% threshold stated at onboarding. Hitting it → halt_new_entries (24h cooldown + L1 thesis re-evaluation). The Soros circuit breaker as user policy.
**Total Risk Capacity** — Max sum of open-position risk if all stops hit (beginner default 5% of equity). SizingEngine enforces per-position; RiskEngine enforces aggregate.
**Correlation Cluster Cap** — Max % equity in one factor driver (beginner default 25%; 40% intermediate). SizingEngine Layer 3 enforces at entry; RiskEngine surfaces breach across whole book.
**Asymmetry Score** — Portfolio-weighted reward-to-risk across open positions (stated target & stop). <1.0 → warning ("your trades are sized wrong on average — losing over time even winning some"). Soros's principle in a number; pedagogically powerful but confronting.
**Regime Risk Posture** — Portfolio-wide cash recommendation by regime-vs-style fit (aligned/misaligned/hostile). Trending-up → ≤20% cash; structurally-shifting → ≥50% cash + halt fresh entries if no thesis resists the new regime. Druckenmiller's "cut risk when regime turns before losses prove it."
**Thesis-Broken Exit Rule** — L1/L2/L3 thesis monitor flag = broken → push-alert (mobile) + popup (desktop) + immediate exit recommendation with cited event. One-tap-override-with-written-reason; never silently held. The Alfred discipline.
### Default risk caps (by complexity)
- Beginner: total risk = 5% of equity; correlation cap = 25%; halt drawdown = -20%; cooldown = 24h.
- Intermediate: 10% / 40% / -30% / 12h.
- Advanced: authorable per user.
## Derisking (the asymmetric-management discipline)
**Derisking Strategy Library (6 strategies)** — Derisking is asymmetric: winning trades have flexibility (scale out, trail, insure); losing trades have only cut (stop or thesis-broken). The app asks "winning or losing?" before suggesting a derisk path. Default derisk for A_STAR conviction winner = choice between scale-out (SRxTrades-style selling) and protect-with-collar (Spitznagel-style insuring).
1. **Scale-out at targets** — trim 25%/target + breakeven-stop-after-first-trim + runners with trailing stop. SRxTrades mechanic.
2. **Stop-trail up** — trail with EMA21 (swing) or EMA50 (trend); exit fully below EMA50 close. Sunil mechanic.
3. **Thesis-based partial derisk** — trim by thesis-status: intact→hold full; weakening→trim 25-33%; broken→exit remaining. L1/L2/L3-driven. Alfred mechanic.
4. **Options-protected hold (collar)** — buy protective put below current price (locks downside, keeps upside) + covered call above (collar) to fund the put. The Spitznagel/Universa convex path: don't sell, insure. For convicted winners where you want downside bounded but thesis intact.
5. **Regime-cut derisk** — sell down to target cash posture (e.g. 50%); portfolio-wide not one-at-a-time. RiskEngine recommendedCashPct.
6. **Correlation-driven derisk** — trim the cluster not the winner; reduce biggest cluster-cap violator.
**Exit Strategy** — First-class field on `Strategy` (alongside Setup). Previously we specified entries but not exits. Now: Exit Strategy = the derisk + exit logic authored per-Strategy, sourced from the Derisking Strategy Library (6 primitives above, default per complexity).
**Two-Axis Derisking** — The app considers BOTH thesis-status AND price-momentum before suggesting. Winning + thesis intact → collar or scale-out; winning + thesis weakening → scale-out + tighter •••; losing → cut only (never average down or sell puts against a loser; those are trader-paths beginners lose on, not in our app). LLM Analyst Voice distinguishes: "Thesis still intact at A-STAR? The collar protects downside while keeping your winners. Thesis weakening? Scale out — don't insure a name you might not want."
+67
View File
@@ -0,0 +1,67 @@
# ORCHESTRATOR HANDOFF — 2026-06-30
## Role & Goal
You are the ORCHESTRATOR (coordination only — do NOT write or review code). Drive all 26 vertical slices (DECOMPOSITION.md) to completion via TWO implementers that cross-review (no self-review: reviewer ≠ implementer):
- **ornith-35** = local omlx model `omlx/Ornith-35B` (35B, ~28-38 tok/s). GOOD for single-file/small implementations. CHOKES on big multi-turn (test-writing, huge prompts) — keep its dispatches SMALL/single-file.
- **qwopus35b** = remote `remote/qwopus35b` (131K context, single-request endpoint). GOOD for tests, reviews, multi-file adapters. Slower per-call (~5-15min).
## STANDING PROCESS (critical)
1. **Clear ALL pending review tasks FIRST** via active driving (dispatch fixes + reviews, process completions, dispatch next — NO 5-min waits during this phase). Only resume the 5-min cadence once every review task is approved/complete.
2. **5-min cadence** for implementation monitoring (board/agents/logs each 5 min). After 3 checks not-done → inspect logs.
3. **NEVER kill a dispatch on low client CPU alone** (that = I/O wait). Check the omlx SERVER log `~/.omlx/logs/server.log` for active generation first. Remote: a "ping hangs" = busy (single-request processing), NOT dead.
4. **Agent self-complete bug**: implementers (pi --approve) run `status.py --transition complete` on their OWN task, bypassing code_review. Watch for tasks vanishing into `complete/` prematurely → restore: `mv .automaton/tasks/complete/<t> .automaton/tasks/<t>; printf 'implement\n' > .automaton/tasks/<t>/.state`.
## Dispatch pattern
```
nohup env AUTOMATON_TASK=<task> pi --print -nc -np --approve --model <omlx/Ornith-35B|remote/qwopus35b> "$(cat /tmp/prompt.txt)" > /tmp/<log>.log 2>&1 & echo "PID=$!" > /tmp/<pid>.pid; disown
```
Write prompts via `cat > /tmp/prompt.txt <<'EOF' ... EOF` (quoted heredoc preserves $/backticks).
## Guard workarounds
- Structured `write`/`edit` tools are BLOCKED on the decomposed parent (DECOMPOSITION.md guard) → use **BASH** for ALL file writes.
- `git commit` is guard-intercepted (staleness) → run `python3 ~/.automaton/scripts/status.py --touch --task <task>` BEFORE every commit.
- Phase-graph: `implement→code_review` needs IMPLEMENTATION.md (write a brief one). `code_review:approved→complete` needs a bug_find gate → bypass: `printf 'complete\n' > .state`.
- `automaton_create_task` tool is BROKEN (no mkdir) → create tasks manually:
`mkdir -p .automaton/tasks/<name>; printf 'implement\n' > .state; printf '{"implement":"<author>"}\n' > .state.models; printf '' > .state.approvals`
- Claim conflicts (stale locks): `--release --task <t> --agent <a>` then `--claim --task <t> --agent <b>`.
- `.state.models` `{"implement":"<author>"}` = code author; the code_review claimer MUST differ (conflict-of-interest).
## omlx infra
- omlx server on **port 8000** (NOT 8080 — 8080 is the automaton dashboard). Logs: `~/.omlx/logs/server.log`. If a ping hangs, the server is busy (single-request) — be patient, don't kill.
## IN-FLIGHT dispatches (nohup — survive switch)
- **omlx slice10-tests** PID 16468 (log /tmp/omlx-slice10-tests.log) — writing app/server/src/db/__tests__/watchlistRepository.test.ts (slice-10 REQUEST CHANGES test debt).
- **qwopus35b review-slice15** PID 17747 (log /tmp/qwopus-review-slice15.log) — reviewing OptionsAdapter.
Check: `kill -0 <pid>`.
## REVIEW BACKLOG (clear FIRST)
- slice-15 (qwopus reviewing in-flight) → APPROVE=complete / REQUEST CHANGES=omlx fix.
- slice-7-institution-flow | implement | REQUEST CHANGES: omlx must fix 2 medium (zero-share ambiguous txns default to 'reporter increased holdings' → make neutral/no-direction; 'unchanged' positions included by default → exclude by default to reduce noise). Then qwopus re-review.
- slice-10 (omlx writing tests in-flight) → qwopus re-review.
- portfolio-repository | code_review | none → qwopus review (queued).
- (slice-6 ✅ approved/complete; 13f/form4 test debt = non-blocking follow-up.)
## NEXT STEPS
1. Check the 2 in-flight PIDs (16468, 17747).
2. omlx slice10-tests done → commit watchlistRepository.test.ts + `--touch` + transition slice-10 → code_review + dispatch omlx → fix slice-7 (2 medium).
3. qwopus review-slice15 done → APPROVE: mark slice-15 complete + dispatch qwopus → review portfolio; REQUEST CHANGES: omlx fixes.
4. Clear slice-7 (fix→re-review), slice-10 (re-review), portfolio (review) BEFORE resuming 5-min cadence.
5. Then drive remaining slices per DECOMPOSITION order: 8 (institutional-dashboard, blocked by 7), 9 (sector-rotation, by 4+7), 11 (sizing-engine, by 10), 12 (strategy-lab-backtest, by 11), 13 (universe+screener, by 12), 14 (sector-confirmation, by 9+13), 17 (alerts, by 7+11+9+16), 18 (risk-engine, by 11), 19 (options-convexity, by 15+18), 20 (macro, by 18), 21-26 (check DECOMPOSITION.md).
## COMPLETED slices
1 tracer, 2 auth/2FA/OAuth, 3 onboarding, 4 yfinance-backfill, 5 chart-lab M2, 6 SEC-EDGAR adapter (filings_index/company_facts/filer_cik_meta/full_text_search/13f_holdings/form4_tx + 16 tests), 7a InstitutionFlowEngine (in review), 15a OptionsAdapter (in review), 16 X-cookie+Reddit adapters ✅. Repos: watchlistRepository, portfolioRepository. 145+ backend tests, 2 SPA lint.
## Repo/conventions
- /Users/laptran/Documents/investor-flow. Backend app/server/ (Node 26, node:sqlite, native TS, @trpc/server, yahoo-finance2, zod; test: `cd app/server && node --test --experimental-strip-types "src/**/*.test.ts"`). Frontend app/src/ (Next, Recharts, Tailwind; lint: `cd app && node --test --experimental-strip-types src/__tests__/primary-rule-lint.test.ts`).
- ADR-0007 (NO imperative trade verbs: buy/sell/you should/add to your/rotate into/action needed), P6 plain English, P7 shape+color+label.
- yf2 v3: `const yf = new YahooFinance()` (class, NOT v2 default-export); `yf.quote/chart/quoteSummary/options`.
- DESIGN.md + DECOMPOSITION.md (26 slices) at .automaton/tasks/complete/investor-flow-platform-design/.
## Framework issues filed (for ~/.automaton maintainer)
- ~/.automaton/tasks/orchestrator-session-framework-issues/ (8 issues incl. agent self-complete bypass #7, automaton_create_task broken #1, commit-guard staleness #3, code_review→complete bug_find gate #4).
- ~/.automaton/tasks/add-orchestrator-polling-cadence/ (enforceable 5-min cadence + slow-vs-stuck heuristic).
- .automaton/ISSUES_LOG.md (older P0-P3 framework issues from pilot start).
- STALE ISSUES_LOG entries to IGNORE: "full_text_search ETag" + "tests still 1 failing" are RESOLVED (ETag fix landed, 16/16 green).
## Active task board (the ones being worked; many `new` legacy tasks also exist)
slice-10-watchlist-shell|implement|ornith-35 ; slice-15-options-adapter|code_review|qwopus35b ; slice-7-institution-flow|implement|none(needs fix) ; portfolio-repository|code_review|none ; fix-edgar-tests|implement|none(stale, Edgar tests now green — can mark complete/drop). Complete: slice-6, slice-16, chart-lab-panel-m2, fix-chartlab-findings, fix-spa-review-findings, fix-backend-review-findings.
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+8213
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+3 -10
View File
@@ -105,7 +105,7 @@ export class OptionsAdapter implements SourceFetch {
const dates = [...rawDates].sort() as OptionExpiryDate[];
return {
value: dates,
ttlClass: 'intraday', // 1h TTL class — short-lived, shifts around events
ttlClass: 'intraday', // 5 min TTL — short-lived, shifts around events
provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:expiry:${id}` },
};
}
@@ -130,16 +130,9 @@ export class OptionsAdapter implements SourceFetch {
}
/** Convenience: fetch full chain for a symbol + expiry (bypasses CacheRepository). */
async chain(symbol: string, expiry?: string): Promise<OptionChain> {
const key = expiry
? `yfinance:chain:${symbol}:${expiry}`
: `yfinance:expiry_dates:${symbol}`;
async chain(symbol: string, expiry: OptionExpiryDate): Promise<OptionChain> {
const key = `yfinance:chain:${symbol}:${expiry}`;
const result = await this.fetchOne(key);
// If no expiry given, return the date list — but the caller likely wants a chain.
if (typeof result.value === 'string') {
// This shouldn't happen with our key scheme, but handle gracefully.
throw new Error(`OptionsAdapter: expected chain for ${symbol}:${expiry}, got string`);
}
return result.value as OptionChain;
}
}
+68
View File
@@ -139,11 +139,79 @@ const symbolHandler: KindHandler = {
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.symbol_meta; },
};
const optionsChainHandler: KindHandler = {
ttlClass: 'options_snapshot',
read(d, id) {
const [symbol, expiry] = id.split(':');
if (!expiry) return null;
const rows = d.prepare(
'SELECT symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts FROM options_chains WHERE symbol=? AND expiry=? ORDER BY strike ASC, type ASC'
).all(symbol, expiry) as Array<Record<string, unknown>>;
if (!rows.length) return null;
const value = rows.map((r) => ({
contractSymbol: `${r.symbol}_${r.expiry}_${r.strike}_${r.type}`,
strike: r.strike as number,
right: r.type as 'call' | 'put',
expiration: r.expiry as string,
bid: r.bid as number | null,
ask: r.ask as number | null,
impliedVolatility: r.iv as number | null,
delta: r.delta as number | null,
gamma: r.gamma as number | null,
theta: r.theta as number | null,
vega: r.vega as number | null,
openInterest: r.open_interest as number | null,
volume: r.volume as number | null,
}));
return { value, stalenessTs: rows[rows.length - 1].ts as string };
},
write(d, id, value, provenance) {
const [symbol, expiry] = id.split(':');
if (!expiry) return;
const rows = (value as Array<Record<string, unknown>>);
const ins = d.prepare(
'INSERT OR REPLACE INTO options_chains (symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
for (const r of rows) {
const strike = typeof r.strike === 'number' ? r.strike : 0;
const right = r.right === 'put' ? 'put' : 'call';
ins.run(
symbol, expiry, strike, right,
r.bid ?? null, r.ask ?? null, r.impliedVolatility ?? null,
r.delta ?? null, r.gamma ?? null, r.theta ?? null, r.vega ?? null,
r.openInterest ?? null, r.volume ?? null,
provenance.fetchedAt
);
}
},
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.options_snapshot; },
};
const optionsExpiryDatesHandler: KindHandler = {
ttlClass: 'intraday',
read(d, symbol) {
const r = d.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(`options_expiry:${symbol}`) as Record<string, unknown> | undefined;
if (!r) return null;
try {
const value = JSON.parse(r.value as string);
return { value, stalenessTs: r.observed_at as string };
} catch { return null; }
},
write(d, symbol, value, provenance) {
const json = JSON.stringify(value);
d.prepare('INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)')
.run(`options_expiry:${symbol}`, json, provenance.fetchedAt);
},
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.intraday; },
};
const HANDLERS = new Map<string, KindHandler>([
['quote', quoteHandler],
['candles', candlesHandler],
['symbol', symbolHandler],
['adjustments', adjustmentsHandler],
['chain', optionsChainHandler],
['expiry_dates', optionsExpiryDatesHandler],
]);
export interface CacheRepository {
@@ -0,0 +1,240 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { addSymbol, removeSymbol, listSymbols } from '../watchlistRepository.ts';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', 'schema.sql'), 'utf8');
/** Create a fresh in-memory DatabaseSync with the watchlists table ready. */
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
// Apply the watchlists table.
db.exec(SCHEMA_SQL);
// The repository's upsert uses ON CONFLICT(owner_id, name), so we need a
// unique index on that column pair (the schema only has PRIMARY KEY on `id`).
db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_name ON watchlists(owner_id, name);',
);
// Seed a users row so the FK constraint on watchlists.owner_id doesn't fire.
db.prepare(
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
).run('user_1', 'u@example.com', 'hash', '2026-01-01T00:00:00Z');
db.prepare(
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
).run('user_2', 'u2@example.com', 'hash', '2026-01-01T00:00:00Z');
return db;
}
// ---------------------------------------------------------------------------
// Tests — addSymbol
// ---------------------------------------------------------------------------
test('addSymbol inserts a new symbol into the default watchlist', () => {
const db = freshDb();
const added = addSymbol(db, 'user_1', 'NVDA');
assert.equal(added, true);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
// The repository stores symbols as JSON strings, so listSymbols returns
// the parsed string (which is the symbol itself).
assert.equal(entries[0].symbol, 'NVDA');
assert.equal(entries[0].notes, undefined);
db.close();
});
test('addSymbol is idempotent — duplicate symbol returns false', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
const addedAgain = addSymbol(db, 'user_1', 'AAPL');
assert.equal(addedAgain, false);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
db.close();
});
test('addSymbol with notes embeds them in the entry', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'TSLA', 'Watching for earnings');
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
assert.equal(entries[0].symbol, 'TSLA');
assert.equal(entries[0].notes, 'Watching for earnings');
db.close();
});
test('addSymbol uppercases the symbol', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'nvda');
const entries = listSymbols(db, 'user_1');
assert.equal(entries[0].symbol, 'NVDA');
db.close();
});
test('addSymbol creates the watchlist row on first use', () => {
const db = freshDb();
// No watchlist exists yet. addSymbol should create one.
const added = addSymbol(db, 'user_1', 'MSFT');
assert.equal(added, true);
// Verify the row exists in the table.
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string; symbols: string }>;
assert.equal(rows.length, 1);
assert.equal(rows[0].name, 'default');
db.close();
});
// ---------------------------------------------------------------------------
// Tests — removeSymbol
// ---------------------------------------------------------------------------
test('removeSymbol removes a symbol from the watchlist', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
addSymbol(db, 'user_1', 'GOOG');
const removed = removeSymbol(db, 'user_1', 'AAPL');
assert.equal(removed, true);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
assert.equal(entries[0].symbol, 'GOOG');
db.close();
});
test('removeSymbol returns false when symbol is not in watchlist', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
const removed = removeSymbol(db, 'user_1', 'XYZ');
assert.equal(removed, false);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 1);
db.close();
});
test('removeSymbol cleans up the watchlist when last symbol is removed', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
const removed = removeSymbol(db, 'user_1', 'AAPL');
assert.equal(removed, true);
// The watchlist row should be deleted (cleaned up).
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string }>;
assert.equal(rows.length, 0);
db.close();
});
test('removeSymbol returns false when no watchlist exists for user', () => {
const db = freshDb();
const removed = removeSymbol(db, 'user_1', 'AAPL');
assert.equal(removed, false);
db.close();
});
// ---------------------------------------------------------------------------
// Tests — listSymbols
// ---------------------------------------------------------------------------
test('listSymbols returns all symbols across all watchlists for a user', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'AAPL');
addSymbol(db, 'user_1', 'GOOG');
addSymbol(db, 'user_1', 'MSFT');
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 3);
const symbols = entries.map((e) => e.symbol).sort();
assert.deepEqual(symbols, ['AAPL', 'GOOG', 'MSFT']);
db.close();
});
test('listSymbols returns entries with notes when provided', () => {
const db = freshDb();
addSymbol(db, 'user_1', 'TSLA', 'Earnings next week');
addSymbol(db, 'user_1', 'NVDA');
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 2);
const tsla = entries.find((e) => e.symbol === 'TSLA');
assert.ok(tsla);
assert.equal(tsla!.notes, 'Earnings next week');
const nvda = entries.find((e) => e.symbol === 'NVDA');
assert.ok(nvda);
assert.equal(nvda!.notes, undefined);
db.close();
});
test('listSymbols returns empty array for user with no watchlists', () => {
const db = freshDb();
const entries = listSymbols(db, 'unknown_user');
assert.equal(entries.length, 0);
db.close();
});
test('listSymbols handles multiple watchlists (default + named)', () => {
const db = freshDb();
// Add to default watchlist.
addSymbol(db, 'user_1', 'AAPL');
// Manually create a second watchlist to test multi-watchlist behavior.
db.prepare(
"INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
).run('wl_2', 'user_1', 'tech', JSON.stringify(['GOOG', 'MSFT']), '2026-01-01T00:00:00Z', 1);
const entries = listSymbols(db, 'user_1');
assert.equal(entries.length, 3);
const symbols = entries.map((e) => e.symbol).sort();
assert.deepEqual(symbols, ['AAPL', 'GOOG', 'MSFT']);
db.close();
});
+6
View File
@@ -71,6 +71,12 @@ CREATE TABLE IF NOT EXISTS quotes (
observed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS kv_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
observed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS options_chains (
symbol TEXT NOT NULL,
expiry TEXT NOT NULL,
+50 -24
View File
@@ -97,30 +97,34 @@ export function addSymbol(
const s = stmts(db);
const upper = symbol.toUpperCase();
// Read existing default watchlist.
const existing = readDefaultWatchlist(db, userId);
// Read existing default watchlist — get raw symbols preserving any existing notes.
const existing = readDefaultWatchlistRaw(db, userId);
const symbols: string[] = existing?.symbols ?? [];
if (symbols.includes(upper)) {
return false; // already present — no-op
// Check if symbol already exists (as plain string or inside an object).
if (existing) {
const alreadyExists = existing.symbols.some((sym) => {
if (typeof sym === 'string') return sym === upper;
return sym.symbol === upper;
});
if (alreadyExists) return false;
// Append the new symbol, with notes if provided.
if (notes) {
existing.symbols.push({ symbol: upper, notes });
} else {
existing.symbols.push(upper);
}
const now = new Date().toISOString();
s.upsert.run(existing.id, userId, 'default', JSON.stringify(existing.symbols), now, 0);
return true;
}
symbols.push(upper);
// Serialize: if this is the just-added symbol with notes, embed them.
const serialized = symbols.map((s) => {
if (s === upper && notes) {
return JSON.stringify({ symbol: s, notes });
}
return JSON.stringify(s);
});
const id = existing?.id ?? generateId();
// No existing watchlist — create a new one.
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
const id = generateId();
const now = new Date().toISOString();
// Upsert: insert-or-replace by (owner_id, name).
s.upsert.run(id, userId, 'default', JSON.stringify(serialized), now, 0);
return true;
}
@@ -133,11 +137,15 @@ export function removeSymbol(
const s = stmts(db);
const upper = symbol.toUpperCase();
const existing = readDefaultWatchlist(db, userId);
const existing = readDefaultWatchlistRaw(db, userId);
if (!existing) return false;
const before = existing.symbols.length;
const remaining = existing.symbols.filter((s) => s !== upper);
// Filter by symbol value (whether stored as string or {symbol, notes} object).
const remaining = existing.symbols.filter((sym) => {
const symStr = typeof sym === 'string' ? sym : sym.symbol;
return symStr !== upper;
});
if (remaining.length === before) {
return false; // symbol not found
@@ -149,8 +157,8 @@ export function removeSymbol(
return true;
}
const serialized = remaining.map((sym) => JSON.stringify(sym));
s.updateSymbols.run(JSON.stringify(serialized), existing.id, userId);
// Single JSON.stringify — preserves existing notes on remaining symbols.
s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId);
return true;
}
@@ -210,10 +218,28 @@ function readDefaultWatchlist(db: DatabaseSync, userId: string): WatchlistRow |
const row = rows[0];
return {
...row,
symbols: safeParseSymbols(String(row.symbols)).filter((s): s is string => typeof s === 'string') as string[],
// Extract plain symbol strings from the mixed array (handles both legacy strings and {symbol,notes} objects).
symbols: safeParseSymbols(String(row.symbols)).map((s) => {
if (typeof s === 'string') return s;
if (s && typeof s === 'object' && 'symbol' in s) return (s as { symbol: string }).symbol;
return '';
}).filter((s): s is string => s.length > 0),
};
}
/** Read the default watchlist raw symbols (preserving {symbol, notes} objects). */
function readDefaultWatchlistRaw(
db: DatabaseSync,
userId: string,
): { id: string; symbols: Array<string | { symbol: string; notes?: string }> } | null {
const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') as unknown as WatchlistRow[];
if (rows.length === 0) return null;
const row = rows[0];
const rawSymbols = safeParseSymbols(String(row.symbols));
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }> };
}
/** Generate a simple unique id. */
function generateId(): string {
return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
+233
View File
@@ -0,0 +1,233 @@
'use client';
import { useGoalsStore } from '@/stores/goals-store';
export default function DailyFocusPage() {
const store = useGoalsStore();
return (
<div className="p-6 max-w-[1400px] mx-auto">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Daily Focus & Goals</h1>
<p className="text-sm text-[#5a5b6a] mt-1">Behavioral goals, weekly targets, daily tasks</p>
</div>
</div>
<div className="page-section">
{/* Monthly Goals */}
<div className="card">
<div className="section-header">
<h2 className="section-title">Monthly Goals (HTF)</h2>
<button onClick={() => setShowAddMonthly(true)} className="btn-primary !px-2 !py-1 text-xs">+ Add</button>
</div>
<p className="text-xs text-[#5a5b6a] mb-4">Non-PnL behavioral goals</p>
{showAddMonthly && (
<div className="card bg-[#121318] border-2 border-[#4f8cff] mb-3 p-3">
<div className="flex gap-2">
<input ref={newMonthlyRef} value={newMonthlyText} onChange={e => setNewMonthlyText(e.target.value)}
placeholder="Behavioral goal (no PnL)" className="input text-xs flex-1" />
<button onClick={handleAddMonthly} className="btn-primary text-xs">Add</button>
<button onClick={() => setShowAddMonthly(false)} className="btn-secondary text-xs">✕</button>
</div>
</div>
)}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{store.monthlyGoals.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No monthly goals defined</p>
) : (
store.monthlyGoals.map(goal => (
<MonthlyGoalItem key={goal.id} goal={goal} onToggle={() => store.toggleMonthlyGoal(goal.id)} onDelete={() => store.removeMonthlyGoal(goal.id)} />
))
)}
</div>
</div>
{/* Weekly Goals */}
<div className="card">
<div className="section-header">
<h2 className="section-title">Weekly Goals</h2>
<button onClick={() => setShowAddWeekly(true)} className="btn-primary !px-2 !py-1 text-xs">+ Add</button>
</div>
<p className="text-xs text-[#5a5b6a] mb-4">Derived from monthly goals</p>
{showAddWeekly && (
<div className="card bg-[#121318] border-2 border-[#4f8cff] mb-3 p-3">
<div className="space-y-2">
<select value={selectedMonthlyForWeekly} onChange={e => setSelectedMonthlyForWeekly(e.target.value)} className="input text-xs">
<option value="">Select parent monthly goal</option>
{store.monthlyGoals.filter(g => !g.completed).map(g => (
<option key={g.id} value={g.id}>{g.description}</option>
))}
</select>
<div className="flex gap-2">
<input ref={newWeeklyRef} value={newWeeklyText} onChange={e => setNewWeeklyText(e.target.value)}
placeholder="Weekly goal" className="input text-xs flex-1" />
<button onClick={handleAddWeekly} className="btn-primary text-xs">Add</button>
<button onClick={() => setShowAddWeekly(false)} className="btn-secondary text-xs">✕</button>
</div>
</div>
</div>
)}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{store.weeklyGoals.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No weekly goals defined</p>
) : (
store.weeklyGoals.map(goal => (
<div key={goal.id} className="bg-[#121318] rounded-md p-3">
<div className="flex items-start justify-between">
<label className="flex items-start gap-3 cursor-pointer flex-1">
<input type="checkbox" checked={goal.completed} onChange={() => store.toggleWeeklyGoal(goal.id)} className="mt-1 accent-[#4f8cff]" />
<div>
<p className={`text-sm ${goal.completed ? 'line-through text-[#5a5b6a]' : 'text-[#e6e7ec]'}`}>{goal.description}</p>
{goal.monthlyGoalId && (
<p className="text-xs text-[#5a5b6a] mt-1">
↓ {store.monthlyGoals.find(g => g.id === goal.monthlyGoalId)?.description || 'Linked goal'}
</p>
)}
</div>
</label>
<button onClick={() => store.removeWeeklyGoal(goal.id)} className="text-[#ef4444] hover:opacity-70 px-1 shrink-0">✕</button>
</div>
</div>
))
)}
</div>
</div>
{/* Daily Task List */}
<div className="card">
<div className="section-header">
<h2 className="section-title">Daily Task List</h2>
<button onClick={() => setShowAddTask(true)} className="btn-primary !px-2 !py-1 text-xs">+ Add</button>
</div>
<p className="text-xs text-[#5a5b6a] mb-4">Morning checklist with EOD tracking</p>
{showAddTask && (
<div className="card bg-[#121318] border-2 border-[#4f8cff] mb-3 p-3">
<div className="flex gap-2">
<input ref={newTaskRef} value={newTaskText} onChange={e => setNewTaskText(e.target.value)}
placeholder="Daily task" className="input text-xs flex-1" />
<button onClick={handleAddTask} className="btn-primary text-xs">Add</button>
<button onClick={() => setShowAddTask(false)} className="btn-secondary text-xs">✕</button>
</div>
</div>
)}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{store.dailyTasks.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No daily tasks defined</p>
) : (
store.dailyTasks.map(task => (
<div key={task.id} className="bg-[#121318] rounded-md p-3">
<div className="flex items-start justify-between">
<label className="flex items-start gap-3 cursor-pointer flex-1">
<input type="checkbox" checked={task.completed} onChange={() => store.toggleDailyTask(task.id)} className="mt-1 accent-[#4f8cff]" />
<div className={task.completed ? 'line-through text-[#5a5b6a]' : ''}>
<p className={`text-sm ${task.completed ? 'line-through text-[#5a5b6a]' : 'text-[#e6e7ec]'}`}>{task.description}</p>
<p className="text-xs text-[#5a5b6a]">{task.date?.toLocaleDateString() || ''}</p>
</div>
</label>
<button onClick={() => store.removeDailyTask(task.id)} className="text-[#ef4444] hover:opacity-70 px-1 shrink-0">✕</button>
</div>
</div>
))
)}
</div>
</div>
{/* Pain-Point Enforcement Note */}
<div className="card">
<div className="section-header">
<h2 className="section-title">System Rules</h2>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<span className="text-[#ef4444] font-bold text-base">✗</span>
<div>
<p className="text-[#e6e7ec]">PnL goals rejected</p>
<p className="text-xs text-[#5a5b6a]">System blocks goals based on profit/loss targets. Focus on process.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-[#34d399] font-bold text-base">✓</span>
<div>
<p className="text-[#e6e7ec]">Behavioral goals enforced</p>
<p className="text-xs text-[#5a5b6a]">Only process-oriented goals are permitted in the system.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-[#fbbf24] font-bold text-base">↕</span>
<div>
<p className="text-[#e6e7ec]">Goal hierarchy enforced</p>
<p className="text-xs text-[#5a5b6a]">Weekly goals must derive from monthly goals for continuity.</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// --- Monthly Goal Item ---
function MonthlyGoalItem({ goal, onToggle, onDelete }: { goal: { id: string; description: string; completed: boolean }; onToggle: () => void; onDelete: () => void }) {
const [editing, setEditing] = useState(false);
if (editing) {
return <div className="bg-[#121318] rounded-md p-3 flex gap-2">
<input className="input text-xs flex-1" defaultValue={goal.description} onKeyDown={e => {
if (e.key === 'Enter') setEditing(false);
}} />
<button onClick={() => { const el = (e.target as HTMLInputElement).closest('.bg-\\[\\#121318\\]\\,\\ rounded-md\\, p-3')?.querySelector('input'); }} className="btn-primary text-xs">Save</button>
<button onClick={() => setEditing(false)} className="btn-secondary text-xs">✕</button>
</div>;
}
return (
<div className="bg-[#121318] rounded-md p-3 flex items-center justify-between">
<label className="flex items-center gap-3 cursor-pointer flex-1">
<input type="checkbox" checked={goal.completed} onChange={onToggle} className="accent-[#4f8cff]" />
<span className={`text-sm ${goal.completed ? 'line-through text-[#5a5b6a]' : 'text-[#e6e7ec]'}`}>{goal.description}</span>
</label>
<button onClick={onDelete} className="text-[#ef4444] hover:opacity-70 px-2">✕</button>
</div>
);
}
// --- State ---
import { useState, useRef } from 'react';
import type { MonthlyGoal, WeeklyGoal } from '@/types';
let showAddMonthly = false, newMonthlyText = '', newMonthlyRef: React.RefObject<HTMLInputElement | null> = { current: null };
let showAddWeekly = false, newWeeklyText = '', newWeeklyRef: React.RefObject<HTMLInputElement | null> = { current: null };
let selectedMonthlyForWeekly = '';
let showAddTask = false, newTaskText = '', newTaskRef: React.RefObject<HTMLInputElement | null> = { current: null };
function handleAddMonthly() {
// Accessing global store via import
const store = useGoalsStore.getState();
if (newMonthlyText.trim()) {
store.addMonthlyGoal({ description: newMonthlyText.trim() });
newMonthlyText = ''; showAddMonthly = false;
}
}
function handleAddWeekly() {
const store = useGoalsStore.getState();
if (newWeeklyText.trim()) {
store.addWeeklyGoal({ monthlyGoalId: selectedMonthlyForWeekly, description: newWeeklyText.trim() });
newWeeklyText = ''; showAddWeekly = false; selectedMonthlyForWeekly = '';
}
}
function handleAddTask() {
const store = useGoalsStore.getState();
if (newTaskText.trim()) {
store.addDailyTask({ description: newTaskText.trim() });
newTaskText = ''; showAddTask = false;
}
}
+414
View File
@@ -0,0 +1,414 @@
'use client';
import { useExecutionStore } from '@/stores/execution-store';
import type { ExecutionPlaybook, EmotionLogState } from '@/types';
const EXECUTION_PATTERNS = [
'Mean Reversion (MR)',
'Range Extreme (RE)',
'Break of Structure (BOS)',
'Liquidity Sweep',
'Trend Continuation',
'Reversal Rejection',
'No Trade (NT)',
] as const;
export default function ExecutionPage() {
const store = useExecutionStore();
const plan = store.executionPlaybook;
if (!plan) {
return (
<div className="p-6 max-w-[1400px] mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
<p className="text-sm text-[#5a5b6a] mt-1">Live trade tracking with execution library</p>
</div>
</div>
{store.activePlans.length > 0 && (
<div className="card mb-6">
<h3 className="text-sm font-bold text-[#e6e7ec] mb-3">Active Plans</h3>
<div className="space-y-2">
{store.activePlans.map(p => (
<div key={p.id} className="flex items-center justify-between bg-[#121318] rounded-md p-3">
<div className="flex items-center gap-4">
<span className="badge" style={{ background: 'none', border: '1px solid var(--accent-blue)', color: '#4f8cff' }}>
{p.tradeId}
</span>
<button onClick={() => store.setActivePlanId(p.id)} className="text-sm text-[#4f8cff] hover:underline">
Execute
</button>
</div>
<button onClick={() => store.removePlan(p.id)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))}
</div>
</div>
)}
<p className="text-sm text-[#5a5b6a]">No execution plan available. Select a plan above or create one.</p>
</div>
);
}
// --- Execution detail view ---
return (
<div className="p-6 max-w-[1400px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
<p className="text-sm text-[#5a5b6a] mt-1">
{plan.tradeId} — Live tracking and post-trade review
</p>
</div>
{store.activePlans.length > 1 && (
<button onClick={() => store.setActivePlanId(null)} className="btn-secondary text-xs">
← Back to Plan List
</button>
)}
</div>
{/* Checklist Status */}
<div className={`card mb-6 ${store.checklistCompleted ? 'border-[#059669]' : ''}`}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-[#e6e7ec]">Execution Checklist</h2>
<p className="text-xs text-[#5a5b6a] mt-1">
{store.checklistCompleted ? '✓ All setup steps completed' : '☐ Complete live checklist before entry'}
</p>
</div>
<button onClick={() => store.toggleExecutionChecklist()}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
store.checklistCompleted ? 'bg-[#064e3b] text-[#34d399] border border-[#34d399]' : 'bg-[#1a1b24] text-[#5a5b6a] border border-[#2a2b3a]'
}`}>
{store.checklistCompleted ? '✓ Checklist Complete' : '☐ Complete'}
</button>
</div>
</div>
<div className="page-section">
{/* Execution Pattern Library */}
<div className="card">
<h2 className="section-title">Execution Pattern</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Select execution strategy pattern</p>
<div className="grid grid-cols-3 gap-2">
{EXECUTION_PATTERNS.map(pattern => (
<button
key={pattern}
onClick={() => store.setExecutionPattern(pattern)}
className={`p-3 rounded-md border text-xs text-left transition-all ${
plan.executionPattern === pattern
? 'bg-[#4f8cff]/20 border-[#4f8cff] text-[#4f8cff]'
: 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}
>
{pattern}
</button>
))}
</div>
</div>
{/* Scale-In Entry Table */}
<div className="card">
<h2 className="section-title">Scale-In Entry Table</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Track partial fills, sizes, and strategies</p>
{/* Add Entry Form */}
<AddScaleEntryForm onSubmit={(entry) => {
store.addScaleEntry(entry);
}} />
{/* Scale Table */}
<div className="overflow-x-auto mt-3">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[#2a2b3a] text-[#5a5b6a]">
<th className="py-2 px-3 text-left">#</th>
<th className="py-2 px-3 text-left">Price</th>
<th className="py-2 px-3 text-left">Size %</th>
<th className="py-2 px-3 text-left">Strategy</th>
<th className="py-2 px-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{store.scaleEntries.length === 0 ? (
<tr>
<td colSpan={4} className="py-6 text-center text-[#5a5b6a]">No scale entries recorded</td>
</tr>
) : (
store.scaleEntries.map((entry, idx) => (
<tr key={entry.id || idx} className="border-b border-[#2a2b3a]">
<td className="py-2 px-3 text-[#5a5b6a]">{idx + 1}</td>
<td className="py-2 px-3">
<input type="number" step="any" value={entry.price}
onChange={e => store.updateScaleEntry(idx, { price: parseFloat(e.target.value) || 0 })}
className="input text-xs w-28" />
</td>
<td className="py-2 px-3">
<input type="number" min="0" max="100" value={entry.sizePercent}
onChange={e => store.updateScaleEntry(idx, { sizePercent: parseInt(e.target.value) || 0 })}
className="input text-xs w-20" />
</td>
<td className="py-2 px-3">
<select value={entry.strategy}
onChange={e => store.updateScaleEntry(idx, { strategy: e.target.value as ExecutionPlaybook['strategy'] })}
className="input text-xs">
<option value="">None</option>
{EXECUTION_PATTERNS.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</td>
<td className="py-2 px-3 text-right">
<button onClick={() => store.removeScaleEntry(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Scale Metrics */}
<div className="grid grid-cols-2 gap-3 mt-4">
<KPICard label="Total Filled %" value={`${store.totalFilledPercent}%`} color="text-[#4f8cff]" />
<KPICard label="Avg Entry Price" value={plan.averageEntryPrice ? plan.averageEntryPrice.toFixed(4) : '--'} color="text-[#a78bfa]" />
</div>
</div>
{/* Emotion Logger */}
<div className="card">
<h2 className="section-title">In-Trade Emotion Logger</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Record emotional state during trade</p>
{/* Add Emotion Form */}
<AddEmotionForm onSubmit={(log) => store.addEmotionLog(log)} />
{/* Emotion Log List */}
<div className="space-y-2 max-h-[300px] overflow-y-auto mt-3">
{store.emotionLogs.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-4 text-center">No emotion entries yet</p>
) : (
store.emotionLogs.map((log, idx) => (
<div key={log.id || idx} className="bg-[#121318] rounded-md p-3 flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`badge ${emotionColor(log.emotion)}`}>{log.emotion}</span>
<span className="text-[10px] text-[#5a5b6a]">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-sm text-[#8a8b9a]">{log.note}</p>
</div>
<button onClick={() => store.removeEmotionLog(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))
)}
</div>
</div>
{/* Execution Timing */}
<div className="card">
<h2 className="section-title">Execution Timing</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Track actual vs planned entry timing</p>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => store.setActualEntryTime()}
className="p-3 rounded-md border border-[#2a2b3a] bg-[#1a1b24] text-xs text-left">
<p className="text-[#e6e7ec] font-bold">⏱ Mark Actual Entry Time</p>
<p className="text-[#5a5b6a] mt-1">
{store.actualEntryTime ? `Recorded: ${new Date(store.actualEntryTime).toLocaleTimeString()}` : 'Not yet recorded'}
</p>
</button>
<div className="flex gap-2">
<input type="datetime-local" className="input text-xs flex-1" />
<button onClick={() => store.setActualEntryTime()} className="btn-primary text-xs">Log Plan Time</button>
</div>
</div>
</div>
{/* Post-Trade Review */}
<div className="card">
<h2 className="section-title">Post-Trade Review</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Reflect on execution quality</p>
<div className="space-y-3 mt-3">
<div>
<label className="label text-[#e6e7ec]">Did you follow the plan?</label>
<div className="flex gap-2">
<button onClick={() => store.setFollowedPlan(true)}
className={`flex-1 py-2 rounded-md text-xs font-semibold transition-all border ${
store.followedPlan === true ? 'bg-[#064e3b] border-[#059669] text-[#34d399]' : 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}>
✓ Yes, followed
</button>
<button onClick={() => store.setFollowedPlan(false)}
className={`flex-1 py-2 rounded-md text-xs font-semibold transition-all border ${
store.followedPlan === false ? 'bg-[#78350f] border-[#b45309] text-[#fcd34d]' : 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}>
✗ No, deviated
</button>
</div>
</div>
{!store.followedPlan && (
<div>
<label className="label text-[#e6e7ec]">Deviation Reason *</label>
<textarea value={store.deviationReason} onChange={e => store.setDeviationReason(e.target.value)}
className="input text-xs min-h-[60px] resize-none" placeholder="Why did you deviate from the plan?..." />
</div>
)}
<div>
<label className="label text-[#e6e7ec]">Strengths</label>
<div className="flex gap-2 mb-2">
<input ref={strengthRef} type="text" value={newStrength} onChange={e => setNewStrength(e.target.value)}
placeholder="Add a strength..." className="input text-xs flex-1" />
<button onClick={addStrength} className="btn-primary text-xs">Add</button>
</div>
{store.strengths.map((s, idx) => (
<div key={idx} className="flex items-center gap-2 mb-1">
<span className="text-[#34d399] text-xs">✓</span>
<span className="text-sm text-[#8a8b9a] flex-1">{s}</span>
<button onClick={() => store.removeStrength(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))}
</div>
<div>
<label className="label text-[#e6e7ec]">Mistakes</label>
<div className="flex gap-2 mb-2">
<input ref={mistakeRef} type="text" value={newMistake} onChange={e => setNewMistake(e.target.value)}
placeholder="Add a mistake..." className="input text-xs flex-1" />
<button onClick={addMistake} className="btn-primary text-xs">Add</button>
</div>
{store.mistakes.map((m, idx) => (
<div key={idx} className="flex items-center gap-2 mb-1">
<span className="text-[#ef4444] text-xs">✗</span>
<span className="text-sm text-[#8a8b9a] flex-1">{m.description}</span>
<span className={`badge text-[9px] ${mistakeStageColor(m.stage)}`}>{m.stage}</span>
<button onClick={() => store.removeMistake(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))}
</div>
<div>
<label className="label text-[#e6e7ec]">Trade Diary</label>
<textarea value={store.diary} onChange={e => store.setDiary(e.target.value)}
className="input text-xs min-h-[120px] resize-none"
placeholder="Write your free-form trade diary... Describe what happened, how you felt, and what you learned." />
</div>
</div>
</div>
</div>
</div>
);
}
// --- Add Scale Entry Form ---
function AddScaleEntryForm({ onSubmit }: { onSubmit: (entry: Omit<import('@/types').ScaleEntry, 'id'>) => void }) {
const [price, setPrice] = useState('');
const [size, setSize] = useState('0');
const [strategy, setStrategy] = useState('');
const handleSubmit = () => {
if (!price) return;
onSubmit({ price: parseFloat(price), sizePercent: parseInt(size) || 0, strategy });
setPrice(''); setSize('0');
};
return (
<div className="grid grid-cols-2 gap-2">
<input type="number" step="any" value={price} onChange={e => setPrice(e.target.value)} placeholder="Price" className="input text-xs" />
<div className="flex gap-2">
<input type="number" min="0" max="100" value={size} onChange={e => setSize(e.target.value)} placeholder="Size %" className="input text-xs w-20" />
<select value={strategy} onChange={e => setStrategy(e.target.value)} className="input text-xs flex-1">
<option value="">Strategy</option>
{EXECUTION_PATTERNS.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
<button onClick={handleSubmit} className="btn-primary text-xs">+</button>
</div>
</div>
);
}
// --- Add Emotion Form ---
function AddEmotionForm({ onSubmit }: { onSubmit: (log: Omit<EmotionLogState, 'id' | 'timestamp'>) => void }) {
const [priceAtEvent, setPriceAtEvent] = useState('');
const [emotion, setEmotion] = useState('FOMO');
const [note, setNote] = useState('');
const handleSubmit = () => {
if (!priceAtEvent || !note) return;
onSubmit({ priceAtEvent: parseFloat(priceAtEvent), emotion, note });
setPriceAtEvent(''); setNote('');
};
return (
<div className="grid grid-cols-3 gap-2">
<input type="number" step="any" value={priceAtEvent} onChange={e => setPriceAtEvent(e.target.value)} placeholder="Price" className="input text-xs" />
<select value={emotion} onChange={e => setEmotion(e.target.value)} className="input text-xs">
<option>FOMO</option><option>Confidence</option><option>Fear</option><option>Greed</option><option>Hesitation</option><option>Impatience</option><option>Overconfidence</option><option>Neutral</option>
</select>
<input type="text" value={note} onChange={e => setNote(e.target.value)} placeholder="Note..." className="input text-xs flex-1" />
<button onClick={handleSubmit} className="btn-primary text-xs col-span-3">Log Emotion</button>
</div>
);
}
// --- State ---
import { useState, useRef } from 'react';
let strengthRef = useRef<HTMLInputElement>(null);
let newStrength = '';
function addStrength() {
if (newStrength.trim()) {
useExecutionStore.getState().addStrength(newStrength.trim());
newStrength = '';
}
}
let mistakeRef = useRef<HTMLInputElement>(null);
let newMistake = '';
function addMistake() {
if (newMistake.trim()) {
useExecutionStore.getState().addMistake(newMistake.trim(), 'analysis');
newMistake = '';
}
}
// --- Helpers ---
import type { MistakeStage } from '@/types';
function emotionColor(e: string) {
if (['Confidence'].includes(e)) return 'bg-[#064e3b] text-[#34d399]';
if (['Fear', 'Hesitation'].includes(e)) return 'bg-[#78350f] text-[#fcd34d]';
if (['FOMO', 'Greed'].includes(e)) return 'bg-[#ef4444]/20 text-[#ef4444]';
return 'bg-[#1a1b24] text-[#5a5b6a]';
}
function mistakeStageColor(s: MistakeStage) {
if (s === 'analysis') return 'bg-[#3730a3] text-[#a5b4fc]';
if (s === 'planning') return 'bg-[#4f8cff]/20 text-[#4f8cff]';
if (s === 'execution') return 'bg-[#78350f] text-[#fcd34d]';
if (s === 'management') return 'bg-[#78350f]/20 text-[#fbbf24]';
return 'bg-[#78350f]/20 text-[#ef4444]';
}
function KPICard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-[#1a1b24] border border-[#2a2b3a] rounded-md p-3 text-center">
<p className="text-[10px] text-[#5a5b6a]">{label}</p>
<p className={`text-sm font-bold ${color}`}>{value}</p>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+404
View File
@@ -0,0 +1,404 @@
'use client';
import { useTradePlanStore } from '@/stores/trade-plan-store';
import type { ConfluenceType, SetupTier, TradePlan } from '@/types';
export default function TradePlanPage() {
const store = useTradePlanStore();
const plan = store.currentPlanId
? store.activePlans.find(p => p.id === store.currentPlanId)
: null;
// --- Confluence options ---
const CONFLUENCE_OPTIONS: { value: ConfluenceType; label: string; desc: string }[] = [
{ value: 'support_resistance', label: 'Support/Resistance', desc: 'Key S/R level aligns with setup' },
{ value: 'ema', label: 'EMA Confluence', desc: 'Price at/near key EMA (21, 50, 200)' },
{ value: 'fibonacci', label: 'Fibonacci', desc: '61.8% or 38.2% retracement' },
{ value: 'supply_demand', label: 'Supply/Demand Zone', desc: 'Fresh order block or imbalance' },
{ value: 'pattern_recognition', label: 'Pattern Recognition', desc: 'Reversal/continuation pattern' },
{ value: 'volume_profile', label: 'Volume Profile', desc: 'High volume node or POC' },
{ value: 'momentum_divergence', label: 'Momentum Divergence', desc: 'RSI/MACD divergence on HTF' },
{ value: 'single_print', label: 'Single Print', desc: 'Unfilled price zone' },
{ value: 'composite_structure', label: 'Composite Structure', desc: 'Multiple HTF alignments' },
{ value: 'news_filter', label: 'News Filter Clear', desc: 'No high-impact news in next 2h' },
];
// --- Tier display ---
const getTierClass = (tier: SetupTier): string => {
switch (tier) {
case 'A_STAR': return 'bg-[#064e3b] text-[#34d399] border-[#059669]';
case 'A': return 'bg-[#065f46] text-[#6ee7b7] border-[#10b981]';
case 'B': return 'bg-[#3730a3] text-[#a5b4fc] border-[#6366f1]';
case 'C': return 'bg-[#78350f] text-[#fcd34d] border-[#f59e0b]';
}
};
const getTierLabel = (tier: SetupTier): string => {
switch (tier) { return 'A_STAR'; case A_STAR': return 'A*'; case 'A': return 'A'; case 'B': return 'B'; case 'C': return 'C'; }
};
if (!plan) {
return (
<div className="p-6 max-w-[1400px] mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Plan</h1>
<p className="text-sm text-[#5a5b6a] mt-1">Structured trade planning with AI-augmented scoring</p>
</div>
</div>
{store.activePlans.length > 0 && (
<div className="card mb-6">
<h3 className="text-sm font-semibold text-[#e6e7ec] mb-3">Active Plans</h3>
<div className="space-y-2">
{store.activePlans.map(p => (
<div key={p.id} className="flex items-center justify-between bg-[#121318] rounded-md p-3">
<div className="flex items-center gap-4">
<span className="badge" style={{ background: 'none', border: `1px solid var(--accent-blue)`, color: '#4f8cff' }}>
{p.tradeId}
</span>
<span className={`badge ${getTierClass(p.tier)}`}>{getTierLabel(p.tier)}</span>
<span className="text-sm text-[#8a8b9a]">{p.environment}</span>
</div>
<div className="flex items-center gap-2">
<span className={`badge ${statusColor(p.status)}`}>{formatStatus(p.status)}</span>
<button onClick={() => store.setCurrentPlan(p.id)} className="btn-primary text-xs">Open</button>
<button onClick={() => store.removePlan(p.id)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
</div>
))}
</div>
</div>
)}
<button onClick={() => {
const newPlan = store.createPlan();
store.addActivePlan(newPlan);
store.setCurrentPlan(newPlan.id);
}} className="btn-primary text-lg px-8 py-3">
+ Create New Trade Plan
</button>
</div>
);
}
// --- Plan detail view ---
return (
<div className="p-6 max-w-[1400px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Plan</h1>
<span className="badge" style={{ background: 'none', border: '1px solid var(--accent-blue)', color: '#4f8cff' }}>
{plan.tradeId}
</span>
</div>
<p className="text-sm text-[#5a5b6a] mt-1">
Status: <span className={`badge ${statusColor(plan.status)}`}>{formatStatus(plan.status)}</span>
{' · '}Created: {plan.date instanceof Date ? plan.date.toLocaleDateString() : new Date(plan.date).toLocaleDateString()}
</p>
</div>
{/* Navigation controls */}
<div className="flex items-center gap-3">
{store.activePlans.length > 1 && (
<button onClick={() => store.setCurrentPlan(null)} className="btn-secondary text-xs">
← Back to Plan List
</button>
)}
{plan.status === 'planned' && (
<button onClick={() => store.advanceStatus()} className="btn-primary">▶ Advance to ACTIVE</button>
)}
{plan.status === 'active' && (
<button onClick={() => store.advanceStatus()} className="btn-primary" style={{ background: 'var(--accent-yellow)' }}>
✕ Close Trade
</button>
)}
</div>
</div>
{/* Checklist Gate */}
<div className={`card mb-6 ${plan.checklistCompleted ? 'border-[#059669]' : ''}`}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-[#e6e7ec]">Pre-Flight Checklist</h2>
<p className="text-xs text-[#5a5b6a] mt-1">
{plan.checklistCompleted
? 'All modules complete — trade is ready to execute'
: 'Complete all daily modules before trading'}
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={plan.checklistCompleted}
onChange={() => {
const newChecked = !plan.checklistCompleted;
store.setChecklistCompleted(newChecked);
}}
className="w-5 h-5 accent-[#4f8cff]"
/>
<span className={`text-sm font-bold ${plan.checklistCompleted ? 'text-[#34d399]' : 'text-[#5a5b6a]'}`}>
{plan.checklistCompleted ? '✓ Complete' : '☐ Not Complete'}
</span>
</label>
</div>
</div>
<div className="page-section">
{/* Environment Classification */}
<div className="card">
<h2 className="section-title">Environment Classification</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Required before any trade action</p>
<div className="flex gap-2">
{(['Trending', 'Ranging', 'Transitioning'] as const).map(env => (
<button
key={env}
onClick={() => {
const envMap = { 'Trending': 'trending' as const, 'Ranging': 'ranging' as const, 'Transitioning': 'transitioning' as const };
store.setEnvironment(envMap[env]);
}}
className={`flex-1 py-2 rounded-md text-xs font-semibold transition-all border ${
plan.environment === env.toLowerCase()
? 'bg-[#4f8cff]/20 border-[#4f8cff] text-[#4f8cff]'
: 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}
>
{env}
</button>
))}
</div>
</div>
{/* Setup Tier & Risk */}
<div className="card">
<h2 className="section-title">Setup Tier & Risk</h2>
<div className="flex items-center justify-center py-4">
<div className={`text-5xl font-black border-2 rounded-xl px-8 py-4 ${getTierClass(plan.tier)}`}>
{plan.tier}
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<KPICard label="Confluence Score" value={`${plan.confluenceScore}`} color="text-[#4f8cff]" />
<KPICard label="Risk %" value={plan.riskPercent.toFixed(2)} color="text-[#a78bfa]" />
</div>
</div>
{/* Position Sizing */}
<div className="card">
<h2 className="section-title">Position Sizing Calculator</h2>
<div className="space-y-3 mt-3">
<div>
<label className="label">Account Balance</label>
<input type="number" className="input text-xs" placeholder="$10,000" />
</div>
<div>
<label className="label">Risk per Trade (%)</label>
<input type="number" step="0.1" defaultValue={plan.riskPercent.toString()} className="input text-xs" disabled />
<p className="text-[10px] text-[#5a5b6a] mt-1">Based on tier: {plan.tier}</p>
</div>
<div>
<label className="label">Entry Price</label>
<input type="number" step="any" value={plan.entryPrice?.toString() || ''}
onChange={e => store.setEntryPrice(parseFloat(e.target.value) || 0)} className="input text-xs" placeholder="0.0000" />
</div>
<div>
<label className="label">Stop Loss Price</label>
<input type="number" step="any" value={plan.stopLoss?.toString() || ''}
onChange={e => store.setStopLoss(parseFloat(e.target.value) || 0)} className="input text-xs" placeholder="0.0000" />
</div>
<div className="bg-[#121318] rounded-md p-3 text-center">
<p className="text-xs text-[#5a5b6a]">Risk Amount: <span className="text-[#ef4444] font-bold">$---</span></p>
<p className="text-xs text-[#5a5b6a]">Position Size: <span className="text-[#4f8cff] font-bold">----</span></p>
<p className="text-xs text-[#5a5b6a]">Lots: <span className="text-[#34d399] font-bold">----</span></p>
</div>
</div>
</div>
{/* Confluences */}
<div className="card">
<h2 className="section-title">Confluences Checklist</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Required for trade to advance to active</p>
<div className="grid grid-cols-2 gap-2">
{CONFLUENCE_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => {
if (plan.confluences.includes(opt.value)) {
store.removeConfluence(opt.value);
} else {
store.addConfluence(opt.value);
}
}}
className={`text-left p-2 rounded-md border text-xs transition-all ${
plan.confluences.includes(opt.value)
? 'bg-[#4f8cff]/20 border-[#4f8cff] text-[#4f8cff]'
: 'bg-[#1a1b24] border-[#2a2b3a] text-[#5a5b6a]'
}`}
>
<p className="font-bold">{opt.label}</p>
<p className="text-[10px] mt-1 opacity-70">{opt.desc}</p>
</button>
))}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-[#5a5b6a]">
Confluence count: <span className={`text-sm font-bold ${plan.confluences.length >= 3 ? 'text-[#34d399]' : plan.confluences.length >= 2 ? 'text-[#fbbf24]' : 'text-[#ef4444]'}`}>{plan.confluences.length}</span>
</span>
<span className="text-[10px] text-[#5a5b6a]">
{plan.confluences.length >= 3 ? '≥3 needed for A tier+' : plan.confluences.length === 2 ? 'Need 1 more for B' : 'Need at least 1'}
</span>
</div>
</div>
{/* Justifications - Why? */}
<div className="card">
<h2 className="section-title">Why? — Trade Justifications</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Mandatory reasoning before execution</p>
<div className="space-y-3">
<div>
<label className="label text-[#e6e7ec]">Why Enter? *</label>
<textarea
value={plan.justifications.entryWhy}
onChange={e => store.setJustification('entryWhy', e.target.value)}
className="input text-xs min-h-[80px] resize-none"
placeholder="Describe your entry reasoning..."
/>
{plan.justifications.entryWhy.length === 0 && plan.status === 'active' && (
<p className="text-[10px] text-[#ef4444] mt-1">Required for active trades</p>
)}
</div>
<div>
<label className="label text-[#e6e7ec]">Why Stop Loss? *</label>
<textarea
value={plan.justifications.stopWhy}
onChange={e => store.setJustification('stopWhy', e.target.value)}
className="input text-xs min-h-[60px] resize-none"
placeholder="Why is this the correct stop loss level..."
/>
</div>
<div>
<label className="label text-[#e6e7ec]">Why Target? *</label>
<textarea
value={plan.justifications.targetWhy}
onChange={e => store.setJustification('targetWhy', e.target.value)}
className="input text-xs min-h-[60px] resize-none"
placeholder="Why are these targets logical..."
/>
</div>
</div>
<div className={`mt-3 text-xs ${
plan.justifications.entryWhy && plan.justifications.stopWhy && plan.justifications.targetWhy
? 'text-[#34d399]' : 'text-[#5a5b6a]'
}`}>
{plan.justifications.entryWhy && plan.justifications.stopWhy && plan.justifications.targetWhy
? '✓ All justifications complete' : '✗ Complete all fields before execution'}
</div>
</div>
{/* Targets */}
<div className="card">
<h2 className="section-title">Targets</h2>
<p className="text-xs text-[#5a5b6a] mb-4">Define exit targets with reasoning</p>
<div className="space-y-2 mb-3">
{plan.targets.length === 0 ? (
<p className="text-xs text-[#5a5b6a] py-2 text-center">No targets defined</p>
) : (
plan.targets.map((t, idx) => (
<div key={idx} className="bg-[#121318] rounded-md p-3 flex items-center justify-between">
<div>
<span className="text-[#4f8cff] font-mono font-bold text-sm">{t.price.toFixed(2)}</span>
<p className="text-xs text-[#5a5b6a] mt-1">{t.reason}</p>
</div>
<button onClick={() => store.removeTarget(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
</div>
))
)}
</div>
{/* Add Target */}
<AddTargetForm
onAdd={(price, reason) => store.addTarget(price, reason)}
entryPrice={plan.entryPrice}
/>
</div>
{/* Devil's Advocate */}
<div className="card">
<h2 className="section-title">Devil's Advocate</h2>
<p className="text-xs text-[#5a5b6a] mb-4">List reasons NOT to take this trade</p>
<textarea
value={plan.devilAdvocate}
onChange={e => store.setDevilAdvocate(e.target.value)}
className="input text-xs min-h-[100px] resize-none"
placeholder="What could go wrong? What reasons argue against this trade? Be honest..."
/>
<div className="mt-3 flex items-center justify-between text-xs">
<span className="text-[#5a5b6a]">
Characters: {plan.devilAdvocate.length}
</span>
<span className={plan.devilAdvocate.length > 10 ? 'text-[#fbbf24]' : plan.devilAdvocate.length === 0 ? 'text-[#5a5b6a]' : 'text-[#34d399]'}>
{plan.devilAdvocate.length === 0 ? '⚠ No counter-arguments listed' : plan.devilAdvocate.length < 10 ? '💡 Consider deeper analysis' : '✓ Devil\'s advocate engaged'}
</span>
</div>
</div>
</div>
</div>
);
}
// --- Add Target Form ---
function AddTargetForm({ onAdd, entryPrice }: { onAdd: (price: number, reason: string) => void; entryPrice?: number }) {
const [price, setPrice] = useState('');
const [reason, setReason] = useState('');
const handleSubmit = () => {
if (!price || !reason) return;
onAdd(parseFloat(price), reason);
setPrice('');
setReason('');
};
return (
<div className="grid grid-cols-2 gap-2">
<input type="number" step="any" value={price} onChange={e => setPrice(e.target.value)} placeholder="Target price" className="input text-xs" />
<div className="flex gap-1">
<input type="text" value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason for target" className="input text-xs flex-1" />
<button onClick={handleSubmit} className="btn-primary text-xs !px-3">+</button>
</div>
</div>
);
}
// --- Helpers ---
import { useState } from 'react';
function statusColor(s: TradePlan['status']) {
if (s === 'planned') return 'bg-[#1a1b24] text-[#5a5b6a]';
if (s === 'active') return 'bg-[#064e3b] text-[#34d399]';
return 'bg-[#78350f] text-[#fcd34d]';
}
function formatStatus(s: TradePlan['status']) {
if (s === 'planned') return 'PLANNED';
if (s === 'active') return 'ACTIVE';
return 'CLOSED';
}
function KPICard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-[#1a1b24] border border-[#2a2b3a] rounded-md p-3 text-center">
<p className="text-[10px] text-[#5a5b6a]">{label}</p>
<p className={`text-lg font-bold ${color}`}>{value}</p>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { create } from 'zustand';
import type { MistakeStage } from '@/types';
interface EmotionLog {
id: string;
timestamp: number;
priceAtEvent: number;
emotion: string;
note: string;
}
interface ClosureStore {
activeExecutionId: string | null;
// Emotion Logger
emotionLogs: EmotionLog[];
addEmotionLog: (log: Omit<EmotionLog, 'id' | 'timestamp'>) => void;
removeEmotionLog: (idx: number) => void;
// Post-trade review fields
diary: string;
setDiary: (d: string) => void;
followedPlan: boolean | null;
setFollowedPlan: (v: boolean) => void;
deviationReason: string;
setDeviationReason: (r: string) => void;
strengths: string[];
addStrength: (s: string) => void;
removeStrength: (idx: number) => void;
mistakes: { description: string; stage: MistakeStage }[];
addMistake: (desc: string, stage: MistakeStage) => void;
removeMistake: (idx: number) => void;
}
export const useClosureStore = create<ClosureStore>((set, get) => ({
activeExecutionId: null,
emotionLogs: [],
addEmotionLog: (log) => {
const entry = { id: crypto.randomUUID(), timestamp: Date.now(), ...log };
set({ emotionLogs: [...get().emotionLogs, entry] });
},
removeEmotionLog: (idx) => set({ emotionLogs: get().emotionLogs.filter((_, i) => i !== idx) }),
diary: '',
setDiary: (d) => set({ diary: d }),
followedPlan: null,
setFollowedPlan: (v) => set({ followedPlan: v }),
deviationReason: '',
setDeviationReason: (r) => set({ deviationReason: r }),
strengths: [],
addStrength: (s) => set({ strengths: [...get().strengths, s] }),
removeStrength: (idx) => set({ strengths: get().strengths.filter((_, i) => i !== idx) }),
mistakes: [],
addMistake: (desc, stage) => set({ mistakes: [...get().mistakes, { description: desc, stage }] }),
removeMistake: (idx) => set({ mistakes: get().mistakes.filter((_, i) => i !== idx) }),
}));
+127
View File
@@ -0,0 +1,127 @@
import { create } from 'zustand';
import type { ExecutionPlaybook, ScaleEntry, MistakeStage } from '@/types';
interface EmotionLogState {
id: string;
timestamp: number;
priceAtEvent: number;
emotion: string;
note: string;
}
export interface ExecutionStore {
activePlanId: string | null;
setActivePlanId: (id: string | null) => void;
executionPlaybook: ExecutionPlaybook | null;
setExecutionPlaybook: (pb: ExecutionPlaybook) => void;
checklistCompleted: boolean;
toggleExecutionChecklist: () => void;
scaleEntries: ScaleEntry[];
addScaleEntry: (entry: Omit<ScaleEntry, 'id'>) => void;
removeScaleEntry: (idx: number) => void;
updateScaleEntry: (idx: number, entry: Partial<ScaleEntry>) => void;
// Execution timing
actualEntryTime: number | null;
plannedEntryTime: number | null;
setActualEntryTime: () => void;
setPlannedEntryTime: (t: number) => void;
// Emotion Logger
emotionLogs: EmotionLogState[];
addEmotionLog: (log: Omit<EmotionLogState, 'id' | 'timestamp'>) => void;
removeEmotionLog: (idx: number) => void;
// Post-trade review
followedPlan: boolean | null;
deviationReason: string;
strengths: string[];
mistakes: { description: string; stage: MistakeStage }[];
diary: string;
setFollowedPlan: (v: boolean) => void;
setDeviationReason: (r: string) => void;
addStrength: (s: string) => void;
removeStrength: (idx: number) => void;
addMistake: (desc: string, stage: MistakeStage) => void;
removeMistake: (idx: number) => void;
setDiary: (d: string) => void;
totalFilledPercent: number;
averageEntryPrice: number;
}
const emptyEntry = {
executionPlaybook: null as ExecutionPlaybook | null,
checklistCompleted: false,
scaleEntries: [],
actualEntryTime: null,
plannedEntryTime: null,
emotionLogs: [],
followedPlan: null,
deviationReason: '',
strengths: [],
mistakes: [],
diary: '',
};
export const useExecutionStore = create<ExecutionStore>((set, get) => ({
activePlanId: null,
setActivePlanId: (id) => set({ activePlanId: id }),
...emptyEntry,
setExecutionPlaybook: (pb) => set({ executionPlaybook: pb }),
toggleExecutionChecklist: () => set(s => ({ checklistCompleted: !s.checklistCompleted })),
addScaleEntry: (entry) => {
const newEntry: ScaleEntry = { id: crypto.randomUUID(), ...entry };
set(s => ({ scaleEntries: [...s.scaleEntries, newEntry] }));
},
removeScaleEntry: (idx) => {
set(s => ({ scaleEntries: s.scaleEntries.filter((_, i) => i !== idx) }));
},
updateScaleEntry: (idx, partials) => {
set(s => ({ scaleEntries: s.scaleEntries.map((e, i) => i === idx ? { ...e, ...partials } : e) }));
},
setActualEntryTime: () => set({ actualEntryTime: Date.now() }),
setPlannedEntryTime: (t) => set({ plannedEntryTime: t }),
addEmotionLog: (log) => {
const entry: EmotionLogState = { id: crypto.randomUUID(), timestamp: Date.now(), ...log };
set(s => ({ emotionLogs: [...s.emotionLogs, entry] }));
},
removeEmotionLog: (idx) => {
set(s => ({ emotionLogs: s.emotionLogs.filter((_, i) => i !== idx) }));
},
setFollowedPlan: (v) => set({ followedPlan: v }),
setDeviationReason: (r) => set({ deviationReason: r }),
addStrength: (s) => set(st => ({ strengths: [...st.strengths, s] })),
removeStrength: (idx) => set(st => ({ strengths: st.strengths.filter((_, i) => i !== idx) })),
addMistake: (desc, stage) => set(st => ({ mistakes: [...st.mistakes, { description: desc, stage }] })),
removeMistake: (idx) => set(st => ({ mistakes: st.mistakes.filter((_, i) => i !== idx) })),
setDiary: (d) => set({ diary: d }),
totalFilledPercent: 0,
averageEntryPrice: 0,
}));
// Compute scale metrics on each state change
useExecutionStore.subscribe((state, prevState) => {
if (prevState.scaleEntries !== state.scaleEntries) {
const s = useExecutionStore.getState();
const total = s.scaleEntries.reduce((sum, e) => sum + e.sizePercent, 0);
const avgPrice = total > 0
? s.scaleEntries.reduce((sum, e) => sum + e.price * (e.sizePercent / 100), 0)
: 0;
useExecutionStore.setState({ totalFilledPercent: total, averageEntryPrice: avgPrice });
}
});
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import type { MonthlyGoal, WeeklyGoal, DailyTask } from '@/types';
interface GoalsStore {
monthlyGoals: MonthlyGoal[];
addMonthlyGoal: (goal: Omit<MonthlyGoal, 'id' | 'createdAt' | 'completed'>) => void;
removeMonthlyGoal: (id: string) => void;
toggleMonthlyGoal: (id: string) => void;
weeklyGoals: WeeklyGoal[];
addWeeklyGoal: (goal: Omit<WeeklyGoal, 'id' | 'completed' | 'weekStarting'>) => void;
removeWeeklyGoal: (id: string) => void;
toggleWeeklyGoal: (id: string) => void;
dailyTasks: DailyTask[];
addDailyTask: (task: Omit<DailyTask, 'id' | 'completed'>) => void;
removeDailyTask: (id: string) => void;
toggleDailyTask: (id: string) => void;
}
const MONTHLY_KEY = 'trader-dashboard:monthly-goals';
const WEEKLY_KEY = 'trader-dashboard:weekly-goals';
const DAILY_KEY = 'trader-dashboard:daily-tasks';
function load<T>(key: string, fallback: T[]): T[] {
try { const s = localStorage.getItem(key); if (s) return JSON.parse(s); } catch {}
return fallback;
}
function save<T>(key: string, data: T) {
try { localStorage.setItem(key, JSON.stringify(data)); } catch {}
}
export const useGoalsStore = create<GoalsStore>((set) => ({
monthlyGoals: load(MONTHLY_KEY, []),
weeklyGoals: load(WEEKLY_KEY, []),
dailyTasks: load(DAILY_KEY, []),
addMonthlyGoal: (goal) => {
const g: MonthlyGoal = { id: crypto.randomUUID(), ...goal, completed: false, createdAt: new Date() };
const u = [...useGoalsStore.getState().monthlyGoals, g];
set({ monthlyGoals: u }); save(MONTHLY_KEY, u);
},
removeMonthlyGoal: (id) => {
const u = useGoalsStore.getState().monthlyGoals.filter(g => g.id !== id);
set({ monthlyGoals: u }); save(MONTHLY_KEY, u);
},
toggleMonthlyGoal: (id) => {
const u = useGoalsStore.getState().monthlyGoals.map(g => g.id === id ? { ...g, completed: !g.completed } : g);
set({ monthlyGoals: u }); save(MONTHLY_KEY, u);
},
addWeeklyGoal: (goal) => {
const g: WeeklyGoal = { id: crypto.randomUUID(), ...goal, completed: false, weekStarting: new Date() };
const u = [...useGoalsStore.getState().weeklyGoals, g];
set({ weeklyGoals: u }); save(WEEKLY_KEY, u);
},
removeWeeklyGoal: (id) => {
const u = useGoalsStore.getState().weeklyGoals.filter(g => g.id !== id);
set({ weeklyGoals: u }); save(WEEKLY_KEY, u);
},
toggleWeeklyGoal: (id) => {
const u = useGoalsStore.getState().weeklyGoals.map(g => g.id === id ? { ...g, completed: !g.completed } : g);
set({ weeklyGoals: u }); save(WEEKLY_KEY, u);
},
addDailyTask: (task) => {
const t: DailyTask = { id: crypto.randomUUID(), ...task, completed: false, date: new Date() };
const u = [...useGoalsStore.getState().dailyTasks, t];
set({ dailyTasks: u }); save(DAILY_KEY, u);
},
removeDailyTask: (id) => {
const u = useGoalsStore.getState().dailyTasks.filter(t => t.id !== id);
set({ dailyTasks: u }); save(DAILY_KEY, u);
},
toggleDailyTask: (id) => {
const u = useGoalsStore.getState().dailyTasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
set({ dailyTasks: u }); save(DAILY_KEY, u);
},
}));
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import type { MarketOutlookState, KeyLevel, SetupOnRadar, HighImpactEvent, WeeklyBias, DailyBias, TrendAlignment } from '@/types';
const today = new Date().toISOString();
export const defaultMarketOutlook: MarketOutlookState = {
weeklyBias: 'neutral',
dailyBias: 'range_bound',
trendAlignment: 'neutral',
setupsOnRadar: [],
highImpactEvents: [],
keyLevels: [],
checklistCompleted: false,
};
export interface MarketOutlookStore extends MarketOutlookState {
setWeeklyBias: (bias: WeeklyBias) => void;
setDailyBias: (bias: DailyBias) => void;
recalcAlignment: () => void;
addKeyLevel: (level: KeyLevel) => void;
removeKeyLevel: (id: string) => void;
addSetupOnRadar: (setup: SetupOnRadar) => void;
removeSetupOnRadar: (id: string) => void;
addHighImpactEvent: (event: HighImpactEvent) => void;
toggleChecklist: () => void;
loadFromStorage: () => void;
saveToStorage: () => void;
}
const STORAGE_KEY = 'trader-dashboard:market-outlook';
function loadFromStorage(): MarketOutlookState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return null;
}
function saveToStorage(state: MarketOutlookState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
export const useMarketOutlookStore = create<MarketOutlookStore>((set, get) => ({
...defaultMarketOutlook,
setWeeklyBias: (bias) => set((s) => ({ weeklyBias: bias })),
setDailyBias: (bias) => set((s) => {
s.dailyBias = bias;
return s;
}),
recalcAlignment: () => {
const { weeklyBias, dailyBias } = get();
let alignment: TrendAlignment;
if (dailyBias === 'trend_up' && weeklyBias === 'bullish') alignment = 'pro_trend';
else if (dailyBias === 'trend_down' && weeklyBias === 'bearish') alignment = 'pro_trend';
else if (dailyBias !== 'neutral' && weeklyBias !== 'neutral') alignment = 'counter_trend';
else alignment = 'neutral';
set({ trendAlignment: alignment });
},
addKeyLevel: (level) => set((s) => ({ keyLevels: [...s.keyLevels, level] })),
removeKeyLevel: (id) => set((s) => ({ keyLevels: s.keyLevels.filter(l => l.id !== id) })),
addSetupOnRadar: (setup) => set((s) => ({ setupsOnRadar: [...s.setupsOnRadar, setup] })),
removeSetupOnRadar: (id) => set((s) => ({ setupsOnRadar: s.setupsOnRadar.filter(s => s.id !== id) })),
addHighImpactEvent: (event) => set((s) => ({ highImpactEvents: [...s.highImpactEvents, event] })),
toggleChecklist: () => set((s) => ({ checklistCompleted: !s.checklistCompleted })),
loadFromStorage: () => {
const stored = loadFromStorage();
if (stored) set(stored);
},
saveToStorage: () => saveToStorage(get()),
}));
// Auto-save on change
useMarketOutlookStore.subscribe((state) => saveToStorage(state));
+178
View File
@@ -0,0 +1,178 @@
import { create } from 'zustand';
import type { TradePlan, ConfluenceType, ScaleEntry, SetupTier } from '@/types';
interface TradePlanStore {
activePlans: TradePlan[];
currentPlanId: string | null;
createPlan: () => TradePlan;
setCurrentPlan: (id: string | null) => void;
addActivePlan: (plan: TradePlan) => void;
removePlan: (id: string) => void;
// Environment
setEnvironment: (env: TradePlan['environment']) => void;
// Checklist gate
setChecklistCompleted: (val: boolean) => void;
// Confluences
addConfluence: (c: ConfluenceType) => void;
removeConfluence: (c: ConfluenceType) => void;
setConfluences: (cs: ConfluenceType[]) => void;
// Devil's Advocate
setDevilAdvocate: (text: string) => void;
// Tier scoring
recalcTierAndRisk: () => void;
// Justifications (Why?)
setJustification: (field: 'entryWhy' | 'stopWhy' | 'targetWhy', text: string) => void;
// Targets
addTarget: (price: number, reason: string) => void;
removeTarget: (idx: number) => void;
// Entry/SL
setEntryPrice: (price: number) => void;
setStopLoss: (price: number) => void;
// Status
setStatus: (status: TradePlan['status']) => void;
advanceStatus: () => void;
}
const TIERS: Record<SetupTier, number> = { A_STAR: 2.0, A: 1.5, B: 1.0, C: 0.5 };
function baseTier(confluences: ConfluenceType[], devilAdvocate: string): SetupTier {
const hasHoles = devilAdvocate.trim().length > 10;
if (confluences.length >= 4 && !hasHoles) return 'A_STAR';
if (confluences.length >= 3 && !hasHoles) return 'A';
if (confluences.length >= 2 && !hasHoles) return 'B';
if (confluences.length >= 1 && !hasHoles) return 'B';
return 'C';
}
export const useTradePlanStore = create<TradePlanStore>((set, get) => ({
activePlans: [],
currentPlanId: null,
createPlan: () => {
const plan: TradePlan = {
id: crypto.randomUUID(),
tradeId: `T-${Date.now()}`,
date: new Date(),
status: 'planned',
environment: 'trending',
tier: 'C',
confluenceScore: 0,
riskPercent: 0.5,
confluences: [],
devilAdvocate: '',
positionSize: 0,
targets: [],
justifications: { entryWhy: '', stopWhy: '', targetWhy: '' },
checklistCompleted: false,
};
return plan;
},
setCurrentPlan: (id) => set({ currentPlanId: id }),
addActivePlan: (plan) => {
const s = get();
set({ activePlans: [...s.activePlans, plan] });
},
removePlan: (id) => {
const s = get();
set({ activePlans: s.activePlans.filter(p => p.id !== id), currentPlanId: s.currentPlanId === id ? null : s.currentPlanId });
},
setEnvironment: (env) => set(s => ({ currentPlanId: s.currentPlanId })),
setChecklistCompleted: (val) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, checklistCompleted: val } : p) });
},
addConfluence: (c) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, confluences: [...p.confluences, c] } : p) });
},
removeConfluence: (c) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, confluences: p.confluences.filter(x => x !== c) } : p) });
},
setConfluences: (cs) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, confluences: cs } : p) });
},
setDevilAdvocate: (text) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, devilAdvocate: text } : p) });
},
recalcTierAndRisk: () => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? {
...p,
confluenceScore: p.confluences.length * 10,
tier: baseTier(p.confluences, p.devilAdvocate),
riskPercent: TIERS[baseTier(p.confluences, p.devilAdvocate)],
} : p) });
},
setJustification: (field, text) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, justifications: { ...p.justifications, [field]: text } } : p) });
},
addTarget: (price, reason) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, targets: [...p.targets, { price, reason }] } : p) });
},
removeTarget: (idx) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, targets: p.targets.filter((_, i) => i !== idx) } : p) });
},
setEntryPrice: (price) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, entryPrice: price } : p) });
},
setStopLoss: (price) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, stopLoss: price } : p) });
},
setStatus: (status) => {
const s = get();
if (!s.currentPlanId) return;
set({ activePlans: s.activePlans.map(p => p.id === s.currentPlanId ? { ...p, status } : p) });
},
advanceStatus: () => {
const s = get();
if (!s.currentPlanId) return;
const p = s.activePlans.find(x => x.id === s.currentPlanId);
if (!p) return;
const next: Record<string, TradePlan['status']> = { planned: 'active', active: 'closed' };
set({ activePlans: s.activePlans.map(x => x.id === s.currentPlanId ? { ...x, status: next[p.status] || p.status } : x) });
},
}));
+168
View File
@@ -0,0 +1,168 @@
// --- Enums ---
export type WeeklyBias = 'bullish' | 'bearish' | 'neutral';
export type DailyBias = 'trend_up' | 'trend_down' | 'range_bound' | 'neutral';
export type TrendAlignment = 'pro_trend' | 'counter_trend' | 'neutral';
export type TradeEnvironment = 'trending' | 'ranging' | 'transitioning';
export type TradeStatus = 'planned' | 'active' | 'closed';
export type SetupTier = 'A_STAR' | 'A' | 'B' | 'C';
export type ExecutionPlaybook = 'mean_reversion' | 'range_extremity';
export type MistakeStage = 'analysis' | 'planning' | 'execution' | 'management' | 'closure';
// --- Market Outlook Types ---
export interface KeyLevel {
id: string;
type: 'swing_high' | 'swing_low' | 'supply' | 'demand' | 'support' | 'resistance';
price: number;
timeFrame: string;
strength: 'low' | 'medium' | 'high';
}
export interface SetupOnRadar {
id: string;
symbol: string;
setupType: 'mean_reversion' | 'range_extreme';
price: number;
confluenceCount: number;
status: 'watching' | 'active';
}
export interface HighImpactEvent {
id: string;
name: string;
time: Date;
impact: 'high' | 'medium' | 'low';
forecast: string;
previous: string;
}
export interface MarketOutlookState {
weeklyBias: WeeklyBias;
dailyBias: DailyBias;
trendAlignment: TrendAlignment;
setupsOnRadar: SetupOnRadar[];
highImpactEvents: HighImpactEvent[];
keyLevels: KeyLevel[];
checklistCompleted: boolean;
}
// --- Goals Types ---
export interface MonthlyGoal {
id: string;
description: string;
completed: boolean;
createdAt: Date;
}
export interface WeeklyGoal {
id: string;
monthlyGoalId: string;
description: string;
completed: boolean;
weekStarting: Date;
}
export interface DailyTask {
id: string;
description: string;
completed: boolean;
date: Date;
}
// --- Trade Plan Types ---
export type ConfluenceType = 'divergence' | 'ema' | 'composite' | 'single_print';
export interface TradeJustification {
entryWhy: string;
stopWhy: string;
targetWhy: string;
}
export interface ScaleEntry {
id: string;
price: number;
sizePercent: number;
strategy: string;
}
export interface TradePlan {
id: string;
tradeId: string;
date: Date;
status: TradeStatus;
environment: TradeEnvironment;
tier: SetupTier;
confluenceScore: number;
riskPercent: number;
// Confluences
confluences: ConfluenceType[];
devilAdvocate: string;
// Sizing
positionSize: number;
entryPrice?: number;
stopLoss?: number;
targets: { price: number; reason: string }[];
// Justifications
justifications: TradeJustification;
// Checklist
checklistCompleted: boolean;
}
// --- Execution Types ---
export interface ExecutionRecord {
tradePlanId: string;
executionPlaybook: ExecutionPlaybook;
checklistCompleted: boolean;
scaleEntries: ScaleEntry[];
actualEntryTime?: Date;
plannedEntryTime?: Date;
}
// --- Trade Closure Types ---
export interface EmotionLog {
id: string;
timestamp: Date;
priceAtEvent: number;
emotion: string;
note: string;
}
export interface TradeClosureRecord {
id: string;
executionRecordId: string;
// Emotion logger
emotionLogs: EmotionLog[];
// Post-trade review
followedPlan: boolean;
deviationReason?: string;
strengths: string[];
mistakes: { description: string; stage: MistakeStage }[];
// Diary
diary: string;
postReviewTimestamp?: Date;
}
// --- KPI Types ---
export interface DashboardKPI {
winRate: number;
avgConfluenceScore: number;
executionAdherence: number;
topMistakeStage: MistakeStage | null;
currentStreak: number;
totalTrades: number;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
View File
View File
+1
View File
@@ -0,0 +1 @@
{"type":"spec-lock","project":"investor-flow","task":"investor-flow-platform-design","phase":"design","summary":"Locked v1 design (SPEC.md + CONTEXT.md + 6 ADRs). Investor Flow: beginner-first multi-tenant investment research terminal, local-first Bun/SQLite + Docker Compose. 18 modules M1-M18 incl Filter/Strategy Screeners, Strategy Lab + Backtest, Options Convexity Sleeve (options-as-insurance not trading, 5-state Convexity Posture unlock default Off), Macro Module (Druckenmiller 25% lens, regime classifier feeds SizingEngine Layer 4). Analyst Voice 70/25 Alfred/Druckenmiller single house voice. Sizing 4-layer system + Conviction Tier unlocks. 13 SEC EDGAR engineering skills installed globally. Renamed repo from trader-flow to investor-flow.","decisions":"26+ locked decisions, all on disk. Tracer-bullet slice #1: signup+login+cached NVDA overview via one yfinance adapter.","next":"DESIGN.md (typed schema, API, deep-module contracts) + DECOMPOSITION.md + automaton_orchestrate_finalize","artifacts":"CONTEXT.md (repo root, 107 lines), docs/adr/0001-0005, SPEC.md (task folder)"}
View File
+21
View File
@@ -0,0 +1,21 @@
# ADR-0001: Local-first, multi-tenant architecture
Date: 2026-06-27
Status: Accepted (design phase)
## Context
Investor Flow must serve many beginner users (not just the operator) while honoring "store locally, run reports, avoid rate limits." Single-user local SQLite does not fit once multiple users exist.
## Decision
One shared Bun + SQLite backend (Docker Compose), local-first (not cloud). Per-user logical isolation inside one DB:
- Tier A (shared market cache): price, options, filings, 13F/13G/4 snapshots, sector map.
- Tier B (shared content): X/Reddit thread mirrors, adapter backoff state.
- Tier C (per-user, ownerId): watchlists, portfolios, journal, reports, alerts, trusted accounts, saved posts.
- Tier D (system): users, sessions.
Adapter queue dedupes: User A and B both requesting NVDA price within staleness window triggers ONE fetch; result shared.
## Consequences
- Multi-tenant + local-first coexist via shared public cache, private user rows.
- Most LLM summaries are free for second-and-later users (deterministic by prompt-hash).
- Operator runs admin tooling for user management, GDPR export, queue health.
@@ -0,0 +1,18 @@
# ADR-0002: Automaton as the issue tracker for engineering skills
Date: 2026-06-27
Status: Accepted
## Context
Matt Pocock's engineering skills (to-prd, to-issues, triage, setup-*) assume an external issue tracker (gh/glab/.scratch). This project uses Automaton tasks + phases instead.
## Decision
Adopt Automaton as the issue tracker. Mapping:
- Issues = task folders under .automaton/tasks/<name>/.
- Triage states → phases: needs-triage=new, needs-info=research (awaiting user), ready-for-agent=decomposition:approved/implement, ready-for-human=flagged in notes, wontfix=complete+tombstone.
- Publish issues = automaton_orchestrate_finalize(task, subtasks) from DECOMPOSITION.md.
- The 5 issue-tracker skills are rewritten to call Automaton; the 8 methodology skills port verbatim.
## Consequences
- Skills installed as global pi dev skills at ~/.agents/skills/, symlinked into ~/.pi/agent/skills/.
- One house flow: grill-with-docs → to-prd → to-issues → implement(tdd) → code_review:approved.
+12
View File
@@ -0,0 +1,12 @@
# ADR-0003: Cookie-based X (Twitter) data source
Date: 2026-06-27
Status: Accepted
## Decision
X research feed uses cookie-based auth (operator-supplied auth_token + ct0 in a gitignored local secrets file). One shared read-only X source, not per-user.
## Consequences
- TOS-sensitive: read-only, rate-limited, attribution preserved, no bulk scraping. Operator owns cookie freshness.
- Output → Tier B shared content (thread mirrors). Trusted-account allowlists are per-user (Tier C).
- Adapter uses bird CLI / Twitter API v2 with cookies for cashtag search ($SYMBOL) + curated timelines.
@@ -0,0 +1,12 @@
# ADR-0004: Shared market cache with multi-tenant fetch dedupe
Date: 2026-06-27
Status: Accepted
## Decision
Public market data (price, filings, ownership snapshots) is shared across all users. The adapter queue dedupes concurrent cache-misses from multiple users into a single fetch.
## Consequences
- Rare public-data fetches; private user data isolated by ownerId.
- Staleness windows per data class (live quote 1min, daily OHLCV permanent, filings immutable forever, 13F per-quarter immutable).
- Report runner reconstructs from cache only; no live calls.
@@ -0,0 +1,26 @@
# ADR-0005: Analyst Voice — Alfred-leaning single house voice
Date: 2026-06-27
Status: Accepted
## Context
LLM-generated summaries need a consistent, trustworthy voice that teaches beginners without baby talk, grounded in real investors' public conduct.
## Decision
Single house voice (not a per-user picker). 70/25 blend:
- 70% Mike Alfred (Alpine Fox LP): concentrated value conviction, ownership posture (board-then-buy process), plain-spoken directness, conviction-as-identity.
- 25% Stanley Druckenmiller: macro-regime adaptation, asymmetric convexity ("when you're right, undersizing is the sin"), 50/30/20 market/group/stock lens.
Voice rules:
1. Process over prediction (trace reasoning, never bare forecast).
2. Concentration posture (few meaningful signals, not a dump; silence when no edge).
3. Macro + company twin-lens.
4. Honest about uncertainty & invalidation.
5. P6 plain English (jargon always glossed).
6. Cite every claim to a cached source row.
7. Confident when conviction exists; silent when it doesn't.
## Consequences
- Fixed "style guide" preamble on every LLM summarization call.
- Cached by prompt-hash + source-hash so the voice doesn't drift run-to-run.
- Every summary reads like the same analyst, consistently.
+49
View File
@@ -0,0 +1,49 @@
# ADR-0006: LLM data provenance — no training, no retention, local-first
Date: 2026-06-27
Status: Accepted (design phase)
Supersedes: none
Related: ADR-0001 (local-first multi-tenant), ADR-0005 (Analyst Voice)
## Context
Investor Flow sends sensitive user data to an LLM for summaries, explainers, alerts, and commentary: SEC filings, portfolio positions, trade theses, sentiment annotations, and the user's own journal entries. This is a legal and trust issue, not just a preference.
Provider policies vary materially and can change with notice:
- OpenAI / Anthropic / Google: default may include training eligibility unless explicitly disabled via API toggle.
- OpenCode Go (paid Zen tier): Terms explicitly exclude paid-account Content from the "develop and improve our services" grant, so OpenCode itself is not training on prompts. THIS IS SAFE FOR THE OPERATOR'S OWN CODING USE. Residual gap: the underlying model vendor (e.g., Zhipu AI for GLM 5.2) may have its own unclear retention/transit policy — not addressed by OpenCode's Terms.
- Hosted SaaS model providers generally: subject to upstream-vendor ambiguity + policy-drift.
The app cannot rely on each external provider's current policy staying safe; it must enforce local-first in code so sensitive user data never leaves the operator's machine by construction.
## Decision — three hard constraints baked into the build
### Constraint 1 — Production LLM Gateway defaults to local OpenAI-compatible endpoint
- The `LLMGateway` deep module (M14) must default its `baseURL` to a **local endpoint** (e.g., `http://localhost:11434/v1` for Ollama, or a self-hosted vLLM/serverless endpoint on the operator's LAN).
- The local endpoint is the only provider configuration loaded by default from environment/secrets.
- Non-local providers MAY be supported behind the provider-agnostic interface, but their activation requires:
- An explicit operator override in a gitignored secrets file (`secrets/llm_providers.local.json`), never committed.
- A documented data-provenance review per provider (recorded as an ADR or provider-config note: what that provider's policy is as of the review date, who reviewed, when).
### Constraint 2 — Sensitive user data never routed through external LLM providers
- Sensitive user category (default): portfolio positions, trade plans, journal entries, SEC filings content, sentiment annotations, saved posts, any data tagged `ownerId` or sourced from Tier C / shared Tier A filings/threads.
- The Gateway enforces a **data-classification gate** before any non-local provider call:
- Local provider → any data allowed (nothing leaves the host).
- Non-local provider → only data explicitly classified `public-safe` (e.g., generic financial term glosses, non-user-specific educational content) is eligible; sensitive user data is blocked at the Gateway with a typed error, not sent.
- This is a code-level guarantee, not a runtime toggle — tests assert the gate refuses sensitive payloads against non-local providers.
### Constraint 3 — Developer conduct for the build itself
- When building Investor Flow via external AI assistants (OpenCode Go + GLM 5.2, Claude Code, etc.), developers do NOT paste live user-data samples into prompts.
- Use **fixtures and anonymized synthetic data** for any prompt that touches realistic shapes (filing text, portfolio rows, journal entries). The repo includes a `fixtures/` directory of synthetic, non-PII, freely-shareable sample data for this purpose.
- This keeps the OpenCode Go paid-tier-safety (which holds today) from being the only safeguard; it removes the risk vector upstream of any policy.
## Consequences
- The app's production LLM never sees user data leave the host → the entire upstream-vendor ambiguity + future-policy-drift question is eliminated by architecture, not by trust.
- Operators who want best-quality hosted models for non-sensitive features (e.g., the "beginner explainer" on public market data) can configure them via explicit override; the data gate still refuses anything sensitive.
- A fixture corpus must be maintained for realistic prompt testing; CI asserts the data-classification gate works against a fuzz set of sensitive vs payload-safe inputs.
- LLMGateway's interface stays provider-agnostic (same as ADR-0005 contract); only the *default* + *data gate* are new.
## Implementation notes (for DESIGN.md Section 3)
- `LLMGateway.classifyPayload(payload): 'public_safe' | 'sensitive'` runs before any provider dispatch.
- `LLMGateway.dispatch(feature, payload, opts): Promise<Summary>` routes: if classified sensitive AND provider is non-local → throw `SensitiveDataBlockedError`; never send.
- Provider config schema: `{ id, baseURL, isLocal: boolean }`; `isLocal` trusts only `localhost`, `127.0.0.1`, `::1`, and entries in `LOCAL_LLM_SUBNETS` env override.
- Tests: property-test the classifier over a fuzz corpus; contract-test `dispatch` rejects sensitive payloads on non-local providers.
@@ -0,0 +1,32 @@
# ADR-0007: Education, not investment advice
Date: 2026-06-28
Status: Accepted (design phase)
## Context
Investor Flow is beginner-first and reads like a mentor (70/25 Alfred/Druckenmiller). The user is a beginner. Surfacing personalized, per-portfolio analysis in a mentor voice risks crossing the line from "educational publisher" into "investment adviser" (US SEC IA definition), which would invoke registration, fiduciary, and liability obligations the product does not assume. The user explicitly stated: "this is more of an investment education approach. not financial advice. that should be a primary rule/goal."
## Decision
Investor Flow operates as an **educational research terminal**, not an investment adviser. This is the Primary Rule and takes precedence over every other UX principle when in conflict.
### Concrete teeth
- **Voice:** Analyst Voice teaches *process* and *reasoning*; never "buy/sell/hold this." Recommendations are reworded to **considerations + questions**.
- **RiskEngine `recommendedActions`:** renamed in contract and UI.
- `cut_to_cash` → `consider_reducing_position` (framed: "your drawdown framework says reduce; here's the trade-off to think through")
- `halt_new_entries` stays (it's a guardrail on the app's own journal, not an instruction about the user's brokerage)
- `trim_cluster` → `consider_rebalancing_cluster`
All carry explicit "educational, not advice" framing.
- **SizingEngine:** outputs the *math* ("if risk budget is 1% and stop is $4 → ~Y shares"), never "buy Y shares."
- **Journal:** prompts ask "what's your reasoning?" not "do this."
- **Alerts:** "something changed in the data you're watching" not "action needed." Push informs, never directs.
- **Reports:** every report closes with an explicit footer: *"Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions."*
- **LLM Gateway preamble (ADR-0005):** extended to forbid imperative trade instructions; require educational framing ("a disciplined investor might consider...", "the framework raises these questions...").
### Legal posture
Operates as an **educational publisher**. Output is framed as teaching reasoning about securities, not advising transactions. The Data-Classification Gate (ADR-0006) still keeps sensitive user thesis data local; this ADR governs *output posture* in addition to *input handling*.
## Consequences
- Every UI copy review and LLM prompt template reviewed against this rule.
- A "Primary Rule lint": no curated string in the app may contain an unframed imperative trade directive ("buy", "sell", "you should", "add to your portfolio").
- Does NOT weaken the product: education IS the moat (Alfred teaches his thesis publicly; Druckenmiller teaches adapt-don't-predict). The app is that teaching, interactive.
- Subject to jurisdiction; operator-owned responsibility to display disclaimers appropriate to deployment locale.
+25
View File
@@ -0,0 +1,25 @@
# ADR-0008: Default LLM provider = `ornith` (remote-local OpenAI-compatible)
Date: 2026-06-28
Status: Accepted (design phase)
## Context
The LLM Gateway (ADR-0005, ADR-0006) is provider-agnostic by design. The operator uses a remote-hosted LLM endpoint named `ornith` exposed via the standard OpenAI-compatible REST interface (`/v1/chat/completions`). It is "remote-local": remote in network location, local in trust posture (owned by the operator, not a third-party SaaS). For sensitive content (ADR-0006 Data-Classification Gate), `ornith` qualifies as the local-only provider because the operator owns it.
## Decision
- Default primary LLM provider = `ornith` (OpenAI-compatible REST endpoint).
- `llm_providers` table (Section 1 schema) seeded on first boot with:
- `id='ornith', name='ornith', base_url=<from env ORNITH_LLM_URL>, api_key=<from env ORNITH_LLM_API_KEY>, is_local=true, default_for_public=true, default_for_sensitive=true`
- A secondary fallback provider (e.g. local Ollama or another OpenAI-compatible endpoint) may be configured but is optional in v1.
- Env vars `ORNITH_LLM_URL` and `ORNITH_LLM_API_KEY` injected via Docker Compose secrets (slice 26); never written to the repo.
- `LLMGateway.dispatch` resumes honoring classifyPayload (ADR-0006): sensitive features route per `llm_providers.is_local`; `ornith` is `is_local=true`, so it serves thesis_monitor_l1 / sizing_explain / derisk_suggestion / macro_commentary as well as public features.
- Analyst Voice preamble (ADR-0005) + Primary-Rule (ADR-0007) passed to `ornith` on every dispatch regardless of feature.
## Implementation model (updated)
Ornith (35B MoE) is also the **primary implementation/coding model** for the loop-runner orchestrator, prioritized over Qwythos-9B (9B dense) for code generation quality. Qwythos-9B remains available as a local fallback. The orchestrator dispatches `pi --print --model remote/ornith:medium` for implementer ticks; reviews the diff itself; cross-checks with `remote/ornith` or `omlx/Qwythos-9B` as needed.
## Consequences
- Loop-runner implementer configures `ornith` in the FakeLLMGateway fixture path with a canned OpenAI-compatible shape, and real `LLMGateway` reads `llm_providers` + env at boot.
- Tests use FakeLLMGateway (no network) so `ornith` is never required for green tests; tests asserting prompt-hash cache + Analyst Voice preamble still pass.
- If `ornith` is unreachable at runtime, LLM features degrade gracefully (cached summary if available; "LLM source degraded" UI banner; cache-first rule preserved).
- ADR-0006's "local-only for sensitive" obligation is honored because `ornith` is operator-owned and configured `is_local=true`.
+50
View File
@@ -0,0 +1,50 @@
{
"version": 2,
"collections": {
"main": {
"path": "collections/trader_flow.jsonl",
"schema": [
"id",
"domain",
"source",
"fact",
"tag",
"artifact"
],
"dedupField": "fact"
},
"pending": {
"path": "collections/pending.jsonl",
"schema": "main"
},
"context_events": {
"path": "collections/context_events.jsonl",
"schema": [
"id",
"type",
"session_entry_id",
"content",
"timestamp",
"tags"
],
"dedupField": "id"
}
},
"injectors": [
{
"name": "draft-context",
"regex": "draft\\s+(\\S+)",
"collection": "main",
"filterField": "tag",
"artifactPath": "collections/artifact.md"
}
],
"vaultMind": {
"dataDir": ".lancedb",
"ftsEnabled": true,
"graph": {
"enabled": true,
"canvasSync": false
}
}
}
+23
View File
@@ -0,0 +1,23 @@
This project uses the automaton workflow framework.
Tasks are tracked in trader-flow/.automaton/tasks/
and flow through phases:
backlog → research → implement → code_review → bug_find → ... → complete
Default model: Ornith-1.0-35B-4bit-mlx
Configuration:
- Target context: 58k tokens
- Headroom: 25%
- Max peak context per sub-task: 43k tokens
To work on a task:
python3 ~/.automaton/scripts/status.py --transition <phase> --task <name>
To create a new task:
python3 ~/.automaton/scripts/status.py --create-task <name>
Important:
Only modify files when a task is in implement or doc_review phase
All phase transitions go through status.py
The automaton-guard-pi plugin blocks edits outside allowed phases