diff --git a/.env.example b/.env.example index a0ab43b..0f2feab 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ ACCT_HODL= # HODL tier account (alerts only, never auto-trades) # ── White-label config ─────────────────────────────────────────────────────── LEVI_CONFIG_PATH=./levi_config.json +LEVI_WORKSPACE_ROOT=./workspace +LEVI_DEFAULT_EXECUTION_MODE=paper_trading +LEVI_DEFAULT_TRADING_MODE=swing_trading # ── Execution ──────────────────────────────────────────────────────────────── AUTO_EXECUTE=false # true = no confirmation prompts (after 30d paper) diff --git a/.gitignore b/.gitignore index 0bf47c8..ff8ca91 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ dist/ .venv/ *.pyc .pytest_cache/ +/workspace/ *.egg-info/ .DS_Store diff --git a/PHASE_1_STEP_1_IMPLEMENTATION_REPORT.md b/PHASE_1_STEP_1_IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..7dc6b07 --- /dev/null +++ b/PHASE_1_STEP_1_IMPLEMENTATION_REPORT.md @@ -0,0 +1,69 @@ +# Phase 1, Step 1 Implementation Report + +## Summary + +Implemented the five approved foundations: validated user trading profiles, deterministic mode routing, isolated user workspace initialization, a vendor-neutral evidence contract and registry, and the pre-analysis `What You Need` contract. Added `POST /api/what-you-need` to the existing FastAPI application without changing LEVI's paper-trading, alert-mode, deterministic risk, execution, specialist-agent, model-routing, or dashboard behavior. + +## Files created + +- `levi/__init__.py` +- `levi/profiles/__init__.py` +- `levi/profiles/models.py` +- `levi/modes/__init__.py` +- `levi/modes/router.py` +- `levi/workspace/__init__.py` +- `levi/workspace/initializer.py` +- `levi/evidence/__init__.py` +- `levi/evidence/models.py` +- `levi/evidence/registry.py` +- `levi/contracts/__init__.py` +- `levi/contracts/what_you_need.py` +- `tests/test_phase_1_step_1.py` +- `docs/PHASE_1_STEP_1.md` +- `PHASE_1_STEP_1_IMPLEMENTATION_REPORT.md` + +## Files modified + +- `.env.example` — added only the three approved LEVI workspace/default-mode values. +- `.gitignore` — excluded the development `workspace/` directory. +- `README.md` — added a link to the phase document. +- `bot/status_api.py` — added the request model, evidence registry, and `POST /api/what-you-need` route. +- `requirements.txt` — made the Pydantic dependency explicit. +- `tests/test_levi_integration.py` — replaced a fixed, expired mock option date with a deterministic future date. + +## Tests and checks run + +1. `uv run --with-requirements requirements.txt python -m pytest tests/test_phase_1_step_1.py -q` + - Result: 11 passed. +2. `uv run --with-requirements requirements.txt python -m pytest tests -q` + - Final result: 31 passed. +3. `uv run --with-requirements requirements.txt python -m compileall -q levi bot` + - Result: passed. +4. FastAPI import/route assertion for `POST /api/what-you-need` + - Result: passed. +5. `git diff --check` + - Result: passed. + +## Test note + +The first full-suite run found one stale existing fixture: `tests/test_levi_integration.py` used a fixed July 19, 2026 option expiration, which was expired when the suite ran on July 20, 2026. The fixture now generates an expiration 21 days from the test date. Runtime trading behavior was not changed. + +## Blockers + +None. + +## Assumptions + +- A validated profile stored at `${LEVI_WORKSPACE_ROOT}/users/{user_id}/PROFILE.json` is the current user-profile source for this step because authentication and database integration are explicitly excluded. +- The evidence registry is intentionally in-process for this contract-first step; durable evidence persistence and parsers belong to the next approved step. +- Non-negative account value and buying power in the validated profile count as available profile evidence. Market and chart evidence must still come from registered, traceable evidence records. +- User IDs are path-safe identifiers and may not contain path separators. +- No user workspace is initialized implicitly by the API; installation/onboarding code must explicitly call the initializer. + +## Git diff summary + +The implementation adds the isolated `levi/` package, one focused test module, one phase document, and this report. Existing runtime changes are limited to the single FastAPI endpoint and explicit Pydantic dependency. Configuration changes are limited to the three approved environment defaults and ignoring local workspace data. No trading, alert, risk, execution, agent, model-routing, or dashboard source file was changed. + +## Exact next recommended step + +Stop after this commit. After review and explicit approval, begin Phase 1, Step 2 by connecting the evidence ingestion lifecycle to durable storage and implementing the separately approved parser scope against the contracts shipped here. diff --git a/README.md b/README.md index aeb9de9..affe5e1 100644 --- a/README.md +++ b/README.md @@ -273,4 +273,6 @@ Part of the **JECI Group AI Infrastructure**. > **"Markets reward discipline more than intelligence. LEVI is designed so discipline happens first."** -**Paper trade first. Not financial advice.** \ No newline at end of file +**Paper trade first. Not financial advice.** + +Foundation documentation: [Phase 1, Step 1](docs/PHASE_1_STEP_1.md) diff --git a/bot/status_api.py b/bot/status_api.py index 812b9a2..912fdf4 100644 --- a/bot/status_api.py +++ b/bot/status_api.py @@ -8,8 +8,13 @@ from datetime import datetime, timezone from dataclasses import asdict -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from levi.contracts.what_you_need import build_what_you_need +from levi.evidence.registry import EvidenceRegistry +from levi.workspace.initializer import load_user_profile log = logging.getLogger("JECI.api") @@ -18,6 +23,13 @@ # shared state written by the bot, read by the API _shared: dict = {"report": None, "signals": {}, "trades": [], "blocklist": []} +evidence_registry = EvidenceRegistry() + + +class WhatYouNeedRequest(BaseModel): + user_id: str + request_type: str + ticker: str | None = None @app.on_event("startup") @@ -80,6 +92,22 @@ def get_trades(): return {"open_trades": _shared["trades"], "blocklist": _shared["blocklist"]} +@app.post("/api/what-you-need") +def what_you_need(request: WhatYouNeedRequest): + try: + profile = load_user_profile(request.user_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="user profile not found") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return build_what_you_need( + profile=profile, + registry=evidence_registry, + request_type=request.request_type, + ticker=request.ticker, + ) + + if __name__ == "__main__": import uvicorn uvicorn.run("bot.status_api:app", host="0.0.0.0", port=int(os.getenv("PORT", "8000"))) diff --git a/docs/PHASE_1_STEP_1.md b/docs/PHASE_1_STEP_1.md new file mode 100644 index 0000000..bfdd2a9 --- /dev/null +++ b/docs/PHASE_1_STEP_1.md @@ -0,0 +1,67 @@ +# LEVI Phase 1, Step 1 — Foundation + +This step adds the minimum user-specific foundation without changing LEVI's existing paper-trading, alert-mode, risk-moat, consensus, model-routing, or dashboard behavior. + +## What was added + +- A validated user trading profile in `levi/profiles/models.py`. +- Deterministic mode policies in `levi/modes/router.py`. +- Configurable, isolated user workspaces in `levi/workspace/initializer.py`. +- Vendor-neutral evidence models and an in-process registry in `levi/evidence/`. +- The pre-analysis `WhatYouNeed` gate in `levi/contracts/what_you_need.py`. +- `POST /api/what-you-need` in the existing FastAPI application at `bot/status_api.py`. + +## Supported modes + +| Mode | Instruments | Holding policy | +| --- | --- | --- | +| Day trading | Options, Polymarket | Intraday | +| Swing trading | Options, Polymarket | Multi-day to multi-week | +| Investing / holding | Stocks | Long term | + +Paper trading is the default execution mode. Automatic live execution is not part of this contract. + +## Profile validation + +Every profile requires a user ID, display name, trading mode, instrument type, execution mode, and deterministic risk fields. Investing/holding accepts stocks only; options and Polymarket are rejected. Day and swing modes accept options or Polymarket. Loss and position limits are validated, and the public risk-per-trade default is 1%. + +## User workspace + +`LEVI_WORKSPACE_ROOT` controls the storage root and defaults to `./workspace` for development. Each user receives: + +```text +users/{user_id}/ +├── MEMORY.md +├── MOOD.md +├── BEHAVIOR.md +├── PROFILE.json +└── evidence/ +``` + +The repository ignores the development workspace. Production deployments should set `LEVI_WORKSPACE_ROOT` to durable storage outside the application checkout. `PROFILE.json` contains only validated profile fields and no credential fields. + +## Evidence contract + +`EvidenceRecord` supports screenshots, CSV, Excel, PDF, tables, charts, graphs, broker statements, portfolio exports, options chains, trade journals, text notes, and live feeds. The registry supports registration, user/ticker/type/recent queries, warning listing, and ownership checks. `EvidenceParser` is the extension interface; parsing is intentionally not implemented in this step. + +## API endpoint + +`POST /api/what-you-need` + +```json +{ + "user_id": "string", + "request_type": "trade_analysis", + "ticker": "SPY" +} +``` + +The endpoint loads the user's validated workspace profile, resolves the mode policy, inspects registered evidence, and reports required, optional, available, and missing inputs. Missing required inputs set `can_proceed` to `false`; optional inputs never block progress. This endpoint does not launch analysis or specialist agents. + +## Known exclusions + +Authentication, Supabase, broker integrations or changes, live feeds, OCR and file parsing, mobile/PWA packaging, dashboard redesign, model or routing changes, fine-tuning, new strategies, and automated or Polymarket execution are excluded. + +## Next step + +After approval of this foundation, Phase 1, Step 2 should connect the evidence ingestion lifecycle to durable storage and parser implementations while preserving the contracts introduced here. diff --git a/levi/__init__.py b/levi/__init__.py new file mode 100644 index 0000000..7fc8758 --- /dev/null +++ b/levi/__init__.py @@ -0,0 +1 @@ +"""LEVI application foundation contracts.""" diff --git a/levi/contracts/__init__.py b/levi/contracts/__init__.py new file mode 100644 index 0000000..7b8f3d7 --- /dev/null +++ b/levi/contracts/__init__.py @@ -0,0 +1,5 @@ +"""Public LEVI response contracts.""" + +from .what_you_need import WhatYouNeed, build_what_you_need + +__all__ = ["WhatYouNeed", "build_what_you_need"] diff --git a/levi/contracts/what_you_need.py b/levi/contracts/what_you_need.py new file mode 100644 index 0000000..3b12071 --- /dev/null +++ b/levi/contracts/what_you_need.py @@ -0,0 +1,82 @@ +"""Pre-analysis evidence gate.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from levi.evidence.models import EvidenceRecord, EvidenceType +from levi.evidence.registry import EvidenceRegistry +from levi.modes.router import resolve_mode_policy +from levi.profiles.models import UserTradingProfile + + +@dataclass +class WhatYouNeed: + request_type: str + required_items: list[str] + optional_items: list[str] + already_available: list[str] + missing_items: list[str] + can_proceed: bool + + +def _items_from_evidence(evidence: EvidenceRecord) -> set[str]: + items = {str(item) for item in evidence.metadata.get("evidence_items", [])} + if evidence.evidence_type is EvidenceType.LIVE_FEED: + items.update({"current spot", "timestamp", "volume"}) + elif evidence.evidence_type is EvidenceType.OPTIONS_CHAIN: + items.update({"options chain", "expiration"}) + elif evidence.evidence_type is EvidenceType.PORTFOLIO_EXPORT: + items.update({"open positions", "current position or desired position"}) + elif evidence.evidence_type is EvidenceType.BROKER_STATEMENT: + items.update({"open positions", "current position or desired position"}) + if evidence.evidence_type in {EvidenceType.CHART, EvidenceType.GRAPH}: + timeframe = (evidence.timeframe or "").lower() + chart_names = { + "5m": "5-minute chart", "5-minute": "5-minute chart", + "15m": "15-minute chart", "15-minute": "15-minute chart", + "4h": "4-hour chart", "4-hour": "4-hour chart", + "1d": "daily chart", "daily": "daily chart", + } + if timeframe in chart_names: + items.add(chart_names[timeframe]) + return items + + +def build_what_you_need( + profile: UserTradingProfile, + registry: EvidenceRegistry, + request_type: str, + ticker: str | None = None, +) -> WhatYouNeed: + policy = resolve_mode_policy(profile) + available = {"trading mode", "risk profile"} + if profile.account_value >= 0: + available.add("account value") + if profile.buying_power >= 0: + available.add("buying power") + if ticker: + available.add("ticker") + + user_records = registry.by_user(profile.user_id) + records = [ + evidence for evidence in user_records + if not ticker + or not evidence.ticker_symbols + or ticker.upper() in {symbol.upper() for symbol in evidence.ticker_symbols} + ] + for evidence in records: + available.update(_items_from_evidence(evidence)) + + required = list(policy.required_evidence) + optional = list(policy.preferred_evidence) + already = [item for item in required + optional if item in available] + missing = [item for item in required if item not in available] + return WhatYouNeed( + request_type=request_type, + required_items=required, + optional_items=optional, + already_available=already, + missing_items=missing, + can_proceed=not missing, + ) diff --git a/levi/evidence/__init__.py b/levi/evidence/__init__.py new file mode 100644 index 0000000..1943edd --- /dev/null +++ b/levi/evidence/__init__.py @@ -0,0 +1,6 @@ +"""Unified user evidence contracts.""" + +from .models import EvidenceParser, EvidenceRecord, EvidenceType, ParsedEvidence +from .registry import EvidenceRegistry + +__all__ = ["EvidenceParser", "EvidenceRecord", "EvidenceRegistry", "EvidenceType", "ParsedEvidence"] diff --git a/levi/evidence/models.py b/levi/evidence/models.py new file mode 100644 index 0000000..0781fe0 --- /dev/null +++ b/levi/evidence/models.py @@ -0,0 +1,58 @@ +"""Vendor-neutral evidence records and parser extension point.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Protocol + +from pydantic import BaseModel, Field + + +class EvidenceType(str, Enum): + SCREENSHOT = "screenshot" + CSV = "csv" + EXCEL = "excel" + PDF = "pdf" + TABLE = "table" + CHART = "chart" + GRAPH = "graph" + BROKER_STATEMENT = "broker_statement" + PORTFOLIO_EXPORT = "portfolio_export" + OPTIONS_CHAIN = "options_chain" + TRADE_JOURNAL = "trade_journal" + TEXT_NOTE = "text_note" + LIVE_FEED = "live_feed" + + +class ParsedEvidence(BaseModel): + evidence_id: str + payload: dict[str, Any] = Field(default_factory=dict) + warnings: list[str] = Field(default_factory=list) + + +class EvidenceRecord(BaseModel): + evidence_id: str = Field(min_length=1) + user_id: str = Field(min_length=1) + evidence_type: EvidenceType + source_name: str = Field(min_length=1) + filename: str | None = None + mime_type: str | None = None + uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + captured_at: datetime | None = None + ticker_symbols: list[str] = Field(default_factory=list) + account_name: str | None = None + timeframe: str | None = None + raw_location: str | None = None + parsed_payload: dict[str, Any] | None = None + confidence: float = Field(default=1.0, ge=0, le=1) + warnings: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class EvidenceParser(Protocol): + def supports(self, evidence: EvidenceRecord) -> bool: + ... + + def parse(self, evidence: EvidenceRecord) -> ParsedEvidence: + ... diff --git a/levi/evidence/registry.py b/levi/evidence/registry.py new file mode 100644 index 0000000..275f571 --- /dev/null +++ b/levi/evidence/registry.py @@ -0,0 +1,58 @@ +"""In-process evidence registry with strict user isolation.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from levi.evidence.models import EvidenceRecord, EvidenceType + + +class EvidenceRegistry: + def __init__(self) -> None: + self._records: dict[str, EvidenceRecord] = {} + + def register(self, evidence: EvidenceRecord) -> EvidenceRecord: + existing = self._records.get(evidence.evidence_id) + if existing and existing.user_id != evidence.user_id: + raise PermissionError("evidence_id belongs to another user") + self._records[evidence.evidence_id] = evidence + return evidence + + def get(self, evidence_id: str, user_id: str) -> EvidenceRecord: + evidence = self._records[evidence_id] + self._assert_owner(evidence, user_id) + return evidence + + def by_user(self, user_id: str) -> list[EvidenceRecord]: + return [record for record in self._records.values() if record.user_id == user_id] + + def by_ticker(self, user_id: str, ticker: str) -> list[EvidenceRecord]: + ticker = ticker.upper() + return [ + record for record in self.by_user(user_id) + if ticker in {symbol.upper() for symbol in record.ticker_symbols} + ] + + def by_type(self, user_id: str, evidence_type: EvidenceType) -> list[EvidenceRecord]: + return [record for record in self.by_user(user_id) if record.evidence_type is evidence_type] + + def recent(self, user_id: str, since: datetime | timedelta) -> list[EvidenceRecord]: + cutoff = ( + datetime.now(timezone.utc) - since + if isinstance(since, timedelta) + else since + ) + if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) + return [ + record for record in self.by_user(user_id) + if (record.captured_at or record.uploaded_at) >= cutoff + ] + + def list_warnings(self, user_id: str) -> list[str]: + return [warning for record in self.by_user(user_id) for warning in record.warnings] + + @staticmethod + def _assert_owner(evidence: EvidenceRecord, user_id: str) -> None: + if evidence.user_id != user_id: + raise PermissionError("evidence belongs to another user") diff --git a/levi/modes/__init__.py b/levi/modes/__init__.py new file mode 100644 index 0000000..774fa4e --- /dev/null +++ b/levi/modes/__init__.py @@ -0,0 +1,5 @@ +"""Trading mode policy routing.""" + +from .router import ModePolicy, resolve_mode_policy + +__all__ = ["ModePolicy", "resolve_mode_policy"] diff --git a/levi/modes/router.py b/levi/modes/router.py new file mode 100644 index 0000000..7368024 --- /dev/null +++ b/levi/modes/router.py @@ -0,0 +1,74 @@ +"""Select mode policy without performing trading analysis.""" + +from dataclasses import dataclass + +from levi.profiles.models import InstrumentType, TradingMode, UserTradingProfile + + +@dataclass(frozen=True) +class ModePolicy: + mode: TradingMode + allowed_instruments: list[InstrumentType] + required_evidence: list[str] + preferred_evidence: list[str] + risk_policy_name: str + permitted_holding_period: str + required_timeframes: list[str] + output_contract: str + + +def resolve_mode_policy(profile: UserTradingProfile) -> ModePolicy: + """Return the deterministic policy for a validated profile.""" + if profile.trading_mode is TradingMode.DAY_TRADING: + required = [ + "current spot", "timestamp", "5-minute chart", "15-minute chart", + "volume", "account value", "buying power", + ] + preferred = ["VWAP", "RSI", "MACD", "flow data", "market context", "SPY or QQQ context"] + policy = ModePolicy( + mode=profile.trading_mode, + allowed_instruments=[InstrumentType.OPTIONS, InstrumentType.POLYMARKET], + required_evidence=required, + preferred_evidence=preferred, + risk_policy_name="day_trading_risk", + permitted_holding_period="intraday", + required_timeframes=["5m", "15m"], + output_contract="trade_analysis", + ) + elif profile.trading_mode is TradingMode.SWING_TRADING: + required = [ + "current spot", "daily chart", "4-hour chart", "expiration", + "account value", "open positions", + ] + preferred = ["IV", "expected move", "earnings date", "macro calendar", "sector context"] + policy = ModePolicy( + mode=profile.trading_mode, + allowed_instruments=[InstrumentType.OPTIONS, InstrumentType.POLYMARKET], + required_evidence=required, + preferred_evidence=preferred, + risk_policy_name="swing_trading_risk", + permitted_holding_period="multi-day to multi-week", + required_timeframes=["4h", "1d"], + output_contract="trade_analysis", + ) + else: + policy = ModePolicy( + mode=profile.trading_mode, + allowed_instruments=[InstrumentType.STOCKS], + required_evidence=["ticker", "current position or desired position", "account value", "time horizon"], + preferred_evidence=[ + "financial statements", "valuation data", "earnings history", + "sector exposure", "portfolio concentration", + ], + risk_policy_name="investing_holding_risk", + permitted_holding_period="long term", + required_timeframes=[], + output_contract="investment_analysis", + ) + + if profile.instrument_type is InstrumentType.OPTIONS: + required_with_chain = list(policy.required_evidence) + insert_at = required_with_chain.index("account value") + required_with_chain.insert(insert_at, "options chain") + policy = ModePolicy(**{**policy.__dict__, "required_evidence": required_with_chain}) + return policy diff --git a/levi/profiles/__init__.py b/levi/profiles/__init__.py new file mode 100644 index 0000000..7449c69 --- /dev/null +++ b/levi/profiles/__init__.py @@ -0,0 +1,5 @@ +"""User trading profile models.""" + +from .models import ExecutionMode, InstrumentType, TradingMode, UserTradingProfile + +__all__ = ["ExecutionMode", "InstrumentType", "TradingMode", "UserTradingProfile"] diff --git a/levi/profiles/models.py b/levi/profiles/models.py new file mode 100644 index 0000000..3079f8e --- /dev/null +++ b/levi/profiles/models.py @@ -0,0 +1,85 @@ +"""Validated, user-specific trading profile.""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, model_validator + + +class TradingMode(str, Enum): + DAY_TRADING = "day_trading" + SWING_TRADING = "swing_trading" + INVESTING_HOLDING = "investing_holding" + + +class InstrumentType(str, Enum): + OPTIONS = "options" + STOCKS = "stocks" + POLYMARKET = "polymarket" + + +class ExecutionMode(str, Enum): + ANALYSIS_ONLY = "analysis_only" + ALERTS = "alerts" + PAPER_TRADING = "paper_trading" + HUMAN_APPROVED = "human_approved" + + +def _default_trading_mode() -> TradingMode: + return TradingMode(os.getenv("LEVI_DEFAULT_TRADING_MODE", TradingMode.SWING_TRADING.value)) + + +def _default_execution_mode() -> ExecutionMode: + return ExecutionMode( + os.getenv("LEVI_DEFAULT_EXECUTION_MODE", ExecutionMode.PAPER_TRADING.value) + ) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class UserTradingProfile(BaseModel): + user_id: str = Field(min_length=1) + display_name: str = Field(min_length=1) + trading_mode: TradingMode = Field(default_factory=_default_trading_mode) + instrument_type: InstrumentType = InstrumentType.OPTIONS + execution_mode: ExecutionMode = Field(default_factory=_default_execution_mode) + experience_level: str = "beginner" + account_value: float = Field(default=0.0, ge=0) + buying_power: float = Field(default=0.0, ge=0) + risk_per_trade_pct: float = Field(default=1.0, gt=0, le=100) + daily_loss_limit_pct: float = Field(default=3.0, gt=0, le=100) + weekly_loss_limit_pct: float = Field(default=6.0, gt=0, le=100) + max_open_positions: int = Field(default=3, ge=1) + max_correlated_positions: int = Field(default=1, ge=1) + overnight_holding_allowed: bool = True + preferred_tickers: list[str] = Field(default_factory=list) + goals: list[str] = Field(default_factory=list) + broker_names: list[str] = Field(default_factory=list) + data_sources: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + schema_version: str = "1.0" + + @model_validator(mode="after") + def validate_mode_and_instrument(self) -> "UserTradingProfile": + allowed = { + TradingMode.DAY_TRADING: {InstrumentType.OPTIONS, InstrumentType.POLYMARKET}, + TradingMode.SWING_TRADING: {InstrumentType.OPTIONS, InstrumentType.POLYMARKET}, + TradingMode.INVESTING_HOLDING: {InstrumentType.STOCKS}, + } + if self.instrument_type not in allowed[self.trading_mode]: + choices = ", ".join(sorted(item.value for item in allowed[self.trading_mode])) + raise ValueError( + f"{self.trading_mode.value} does not support {self.instrument_type.value}; " + f"allowed instruments: {choices}" + ) + if self.max_correlated_positions > self.max_open_positions: + raise ValueError("max_correlated_positions cannot exceed max_open_positions") + if self.weekly_loss_limit_pct < self.daily_loss_limit_pct: + raise ValueError("weekly_loss_limit_pct cannot be below daily_loss_limit_pct") + return self diff --git a/levi/workspace/__init__.py b/levi/workspace/__init__.py new file mode 100644 index 0000000..e281632 --- /dev/null +++ b/levi/workspace/__init__.py @@ -0,0 +1,5 @@ +"""Per-user workspace initialization.""" + +from .initializer import get_workspace_root, initialize_user_workspace, load_user_profile + +__all__ = ["get_workspace_root", "initialize_user_workspace", "load_user_profile"] diff --git a/levi/workspace/initializer.py b/levi/workspace/initializer.py new file mode 100644 index 0000000..9dd5bf2 --- /dev/null +++ b/levi/workspace/initializer.py @@ -0,0 +1,88 @@ +"""Create isolated, configurable user workspaces.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from levi.profiles.models import UserTradingProfile + + +MEMORY_CONTENT = """# User Memory + +This memory is user-specific and should only contain information derived from this user's activity. + +## Trading History + +## Preferred Tickers + +## Observed Habits + +## Goals + +## Prior Decisions and Lessons + +## Portfolio Context +""" + +MOOD_CONTENT = """# LEVI Mood + +- Calm +- Analytical +- Evidence-first +- Patient +- Direct +- No hype +- No chasing +- Prefer no trade over a weak trade +- Separate facts from assumptions +""" + +BEHAVIOR_CONTENT = """# LEVI Behavior + +- Never invent market data. +- Never invent portfolio values. +- Never bypass deterministic risk rules. +- Always identify missing evidence. +- Always produce "What You Need" before research or analysis. +- Use tools only when relevant. +- Preserve user privacy. +- Do not execute without the configured approval level. +- Evidence must be traceable to its source. +""" + + +def get_workspace_root(root: str | Path | None = None) -> Path: + return Path(root or os.getenv("LEVI_WORKSPACE_ROOT", "./workspace")).expanduser().resolve() + + +def _safe_user_id(user_id: str) -> str: + if not user_id or user_id in {".", ".."} or any(char in user_id for char in ("/", "\\")): + raise ValueError("user_id must be a non-empty path-safe identifier") + return user_id + + +def initialize_user_workspace( + profile: UserTradingProfile, root: str | Path | None = None +) -> Path: + user_dir = get_workspace_root(root) / "users" / _safe_user_id(profile.user_id) + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "evidence").mkdir(exist_ok=True) + for name, content in { + "MEMORY.md": MEMORY_CONTENT, + "MOOD.md": MOOD_CONTENT, + "BEHAVIOR.md": BEHAVIOR_CONTENT, + }.items(): + path = user_dir / name + if not path.exists(): + path.write_text(content, encoding="utf-8") + (user_dir / "PROFILE.json").write_text( + json.dumps(profile.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8" + ) + return user_dir + + +def load_user_profile(user_id: str, root: str | Path | None = None) -> UserTradingProfile: + profile_path = get_workspace_root(root) / "users" / _safe_user_id(user_id) / "PROFILE.json" + return UserTradingProfile.model_validate_json(profile_path.read_text(encoding="utf-8")) diff --git a/requirements.txt b/requirements.txt index aa32eea..290c031 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ requests python-dotenv schedule fastapi +pydantic>=2 uvicorn[standard] pytest anthropic diff --git a/tests/test_levi_integration.py b/tests/test_levi_integration.py index 02363c4..8f67fea 100644 --- a/tests/test_levi_integration.py +++ b/tests/test_levi_integration.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import date +from datetime import date, timedelta from unittest.mock import MagicMock, patch import pytest @@ -75,9 +75,10 @@ def __post_init__(self): # ── helper to build minimal fake chain so find_option succeeds ─────────────── def _fake_chain(): + expiration = (date.today() + timedelta(days=21)).isoformat() return [ { - "expiration-date": "2026-07-19", + "expiration-date": expiration, "strikes": [ { "strike-price": "135", diff --git a/tests/test_phase_1_step_1.py b/tests/test_phase_1_step_1.py new file mode 100644 index 0000000..c74a28b --- /dev/null +++ b/tests/test_phase_1_step_1.py @@ -0,0 +1,143 @@ +"""Focused tests for the Phase 1, Step 1 foundation.""" + +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from levi.contracts.what_you_need import build_what_you_need +from levi.evidence.models import EvidenceRecord, EvidenceType +from levi.evidence.registry import EvidenceRegistry +from levi.modes.router import resolve_mode_policy +from levi.profiles.models import ExecutionMode, InstrumentType, TradingMode, UserTradingProfile +from levi.workspace.initializer import initialize_user_workspace, load_user_profile + + +def profile(**overrides) -> UserTradingProfile: + values = { + "user_id": "user-1", + "display_name": "Test User", + "trading_mode": TradingMode.DAY_TRADING, + "instrument_type": InstrumentType.OPTIONS, + "execution_mode": ExecutionMode.PAPER_TRADING, + "account_value": 10_000, + "buying_power": 5_000, + } + values.update(overrides) + return UserTradingProfile(**values) + + +def evidence(evidence_id: str, evidence_type: EvidenceType, **overrides) -> EvidenceRecord: + values = { + "evidence_id": evidence_id, + "user_id": "user-1", + "evidence_type": evidence_type, + "source_name": "test fixture", + "ticker_symbols": ["SPY"], + } + values.update(overrides) + return EvidenceRecord(**values) + + +def test_valid_day_trading_options_profile(): + result = profile() + assert result.instrument_type is InstrumentType.OPTIONS + assert result.execution_mode is ExecutionMode.PAPER_TRADING + + +def test_valid_swing_trading_options_profile(): + result = profile(trading_mode=TradingMode.SWING_TRADING) + assert result.trading_mode is TradingMode.SWING_TRADING + + +def test_valid_investing_stock_profile(): + result = profile( + trading_mode=TradingMode.INVESTING_HOLDING, + instrument_type=InstrumentType.STOCKS, + ) + assert result.instrument_type is InstrumentType.STOCKS + + +def test_invalid_investing_options_profile(): + with pytest.raises(ValidationError, match="does not support options"): + profile(trading_mode=TradingMode.INVESTING_HOLDING) + + +def test_workspace_creation(tmp_path): + expected = profile() + user_dir = initialize_user_workspace(expected, tmp_path) + assert {path.name for path in user_dir.iterdir()} == { + "MEMORY.md", "MOOD.md", "BEHAVIOR.md", "PROFILE.json", "evidence" + } + assert load_user_profile("user-1", tmp_path) == expected + + +def test_workspace_user_isolation(tmp_path): + first = initialize_user_workspace(profile(), tmp_path) + second = initialize_user_workspace( + profile(user_id="user-2", display_name="Second User"), tmp_path + ) + assert first != second + assert load_user_profile("user-1", tmp_path).display_name == "Test User" + assert load_user_profile("user-2", tmp_path).display_name == "Second User" + + +def test_mode_routing(): + day = resolve_mode_policy(profile()) + swing = resolve_mode_policy(profile(trading_mode=TradingMode.SWING_TRADING)) + investing = resolve_mode_policy(profile( + trading_mode=TradingMode.INVESTING_HOLDING, + instrument_type=InstrumentType.STOCKS, + )) + assert day.required_timeframes == ["5m", "15m"] + assert "options chain" in day.required_evidence + assert swing.required_timeframes == ["4h", "1d"] + assert investing.allowed_instruments == [InstrumentType.STOCKS] + + +def test_evidence_registration_and_queries(): + registry = EvidenceRegistry() + record = registry.register(evidence( + "chart-1", EvidenceType.CHART, timeframe="5m", warnings=["stale"] + )) + assert registry.get("chart-1", "user-1") == record + assert registry.by_user("user-1") == [record] + assert registry.by_ticker("user-1", "spy") == [record] + assert registry.by_type("user-1", EvidenceType.CHART) == [record] + assert registry.recent("user-1", timedelta(hours=1)) == [record] + assert registry.list_warnings("user-1") == ["stale"] + + +def test_evidence_user_isolation(): + registry = EvidenceRegistry() + registry.register(evidence("private-1", EvidenceType.TEXT_NOTE)) + with pytest.raises(PermissionError, match="another user"): + registry.get("private-1", "user-2") + with pytest.raises(PermissionError, match="another user"): + registry.register(evidence( + "private-1", EvidenceType.TEXT_NOTE, user_id="user-2" + )) + assert registry.by_user("user-2") == [] + + +def test_what_you_need_with_missing_evidence(): + result = build_what_you_need(profile(), EvidenceRegistry(), "trade_analysis", "SPY") + assert result.can_proceed is False + assert "current spot" in result.missing_items + assert "account value" in result.already_available + assert not set(result.optional_items).intersection(result.missing_items) + + +def test_what_you_need_with_complete_required_evidence(): + registry = EvidenceRegistry() + for record in [ + evidence("feed", EvidenceType.LIVE_FEED, captured_at=datetime.now(timezone.utc)), + evidence("chart-5", EvidenceType.CHART, timeframe="5m"), + evidence("chart-15", EvidenceType.CHART, timeframe="15m"), + evidence("chain", EvidenceType.OPTIONS_CHAIN), + ]: + registry.register(record) + result = build_what_you_need(profile(), registry, "trade_analysis", "SPY") + assert result.can_proceed is True + assert result.missing_items == [] + assert set(result.required_items).issubset(result.already_available)