diff --git a/.claude/skills/desloppify/SKILL.md b/.claude/skills/desloppify/SKILL.md new file mode 100644 index 0000000..647d4b9 --- /dev/null +++ b/.claude/skills/desloppify/SKILL.md @@ -0,0 +1,308 @@ +--- +name: desloppify +description: > + Codebase health scanner and technical debt tracker. Use when the user asks + about code quality, technical debt, dead code, large files, god classes, + duplicate functions, code smells, naming issues, import cycles, or coupling + problems. Also use when asked for a health score, what to fix next, or to + create a cleanup plan. Supports 29 languages. +allowed-tools: Bash(desloppify *) +--- + + + + +# Desloppify + +## 1. Your Job + +Maximise the **strict score** honestly. Your main cycle: **scan → plan → execute → rescan**. Follow the scan output's **INSTRUCTIONS FOR AGENTS** — don't substitute your own analysis. + +**Don't be lazy.** Do large refactors and small detailed fixes with equal energy. If it takes touching 20 files, touch 20 files. If it's a one-line change, make it. No task is too big or too small — fix things properly, not minimally. + +## 2. The Workflow + +Three phases, repeated as a cycle. + +### Phase 1: Scan and review — understand the codebase + +```bash +desloppify scan --path . # analyse the codebase +desloppify status # check scores — are we at target? +``` + +The scan will tell you if subjective dimensions need review. Follow its instructions. To trigger a review manually: +```bash +desloppify review --prepare # then follow your runner's review workflow +``` + +### Phase 2: Plan — decide what to work on + +After reviews, triage stages and plan creation appear in the execution queue surfaced by `next`. Complete them in order — `next` tells you what each stage expects in the `--report`: +```bash +desloppify next # shows the next execution workflow step +desloppify plan triage --stage observe --report "themes and root causes..." +desloppify plan triage --stage reflect --report "comparison against completed work..." +desloppify plan triage --stage organize --report "summary of priorities..." +desloppify plan triage --complete --strategy "execution plan..." +``` + +For automated triage: `desloppify plan triage --run-stages --runner codex` (Codex) or `--runner claude` (Claude). Options: `--only-stages`, `--dry-run`, `--stage-timeout-seconds`. + +Then shape the queue. **The plan shapes everything `next` gives you** — `next` is the execution queue, not the full backlog. Don't skip this step. + +```bash +desloppify plan # see the living plan details +desloppify plan queue # compact execution queue view +desloppify plan reorder top # reorder — what unblocks the most? +desloppify plan cluster create # group related issues to batch-fix +desloppify plan focus # scope next to one cluster +desloppify plan skip # defer — hide from next +``` + +### Phase 3: Execute — grind the queue to completion + +Trust the plan and execute. Don't rescan mid-queue — finish the queue first. + +**Branch first.** Create a dedicated branch — never commit health work directly to main: +```bash +git checkout -b desloppify/code-health # or desloppify/ +desloppify config set commit_pr 42 # link a PR for auto-updated descriptions +``` + +**The loop:** +```bash +# 1. Get the next item from the execution queue +desloppify next + +# 2. Fix the issue in code + +# 3. Resolve it (next shows the exact command including required attestation) + +# 4. When you have a logical batch, commit and record +git add && git commit -m "desloppify: fix 3 deferred_import findings" +desloppify plan commit-log record # moves findings uncommitted → committed, updates PR + +# 5. Push periodically +git push -u origin desloppify/code-health + +# 6. Repeat until the queue is empty +``` + +Score may temporarily drop after fixes — cascade effects are normal, keep going. +If `next` suggests an auto-fixer, run `desloppify autofix --dry-run` to preview, then apply. + +**When the queue is clear, go back to Phase 1.** New issues will surface, cascades will have resolved, priorities will have shifted. This is the cycle. + +## 3. Reference + +### Key concepts + +- **Tiers**: T1 auto-fix → T2 quick manual → T3 judgment call → T4 major refactor. +- **Auto-clusters**: related findings are auto-grouped in `next`. Drill in with `next --cluster `. +- **Zones**: production/script (scored), test/config/generated/vendor (not scored). Fix with `zone set`. +- **Wontfix cost**: widens the lenient↔strict gap. Challenge past decisions when the gap grows. + +### Scoring + +Overall score = **25% mechanical** + **75% subjective**. + +- **Mechanical (25%)**: auto-detected issues — duplication, dead code, smells, unused imports, security. Fixed by changing code and rescanning. +- **Subjective (75%)**: design quality review — naming, error handling, abstractions, clarity. Starts at **0%** until reviewed. The scan will prompt you when a review is needed. +- **Strict score** is the north star: wontfix items count as open. The gap between overall and strict is your wontfix debt. +- **Score types**: overall (lenient), strict (wontfix counts), objective (mechanical only), verified (confirmed fixes only). + +### Reviews + +Four paths to get subjective scores: + +- **Local runner (Codex)**: `desloppify review --run-batches --runner codex --parallel --scan-after-import` — automated end-to-end. +- **Local runner (Claude)**: `desloppify review --prepare` → launch parallel subagents → `desloppify review --import merged.json` — see skill doc overlay for details. +- **Cloud/external**: `desloppify review --external-start --external-runner claude` → follow session template → `--external-submit`. +- **Manual path**: `desloppify review --prepare` → review per dimension → `desloppify review --import file.json`. + +- Import first, fix after — import creates tracked state entries for correlation. +- Target-matching scores trigger auto-reset to prevent gaming. Use the blind-review workflow described in your agent overlay doc (e.g. `docs/CLAUDE.md`, `docs/HERMES.md`). +- Even moderate scores (60-80) dramatically improve overall health. +- Stale dimensions auto-surface in `next` — just follow the queue. + +**Integrity rules:** Score from evidence only — no prior chat context, score history, or target-threshold anchoring. When evidence is mixed, score lower and explain uncertainty. Assess every requested dimension; never drop one. + +#### Review output format + +Return machine-readable JSON for review imports. For `--external-submit`, include `session` from the generated template: + +```json +{ + "session": { + "id": "", + "token": "" + }, + "assessments": { + "": 0 + }, + "findings": [ + { + "dimension": "", + "identifier": "short_id", + "summary": "one-line defect summary", + "related_files": ["relative/path/to/file.py"], + "evidence": ["specific code observation"], + "suggestion": "concrete fix recommendation", + "confidence": "high|medium|low" + } + ] +} +``` + +`findings` MUST match `query.system_prompt` exactly (including `related_files`, `evidence`, and `suggestion`). Use `"findings": []` when no defects found. Import is fail-closed: invalid findings abort unless `--allow-partial` is passed. Assessment scores are auto-applied from trusted internal or cloud session imports. Legacy `--attested-external` remains supported. + +#### Import paths + +- Robust session flow (recommended): `desloppify review --external-start --external-runner claude` → use generated prompt/template → run printed `--external-submit` command. +- Durable scored import (legacy): `desloppify review --import findings.json --attested-external --attest "I validated this review was completed without awareness of overall score and is unbiased."` +- Findings-only fallback: `desloppify review --import findings.json` + +#### Reviewer agent prompt + +Runners that support agent definitions (Cursor, Copilot, Gemini) can create a dedicated reviewer agent. Use this system prompt: + +``` +You are a code quality reviewer. You will be given a codebase path, a set of +dimensions to score, and what each dimension means. Read the code, score each +dimension 0-100 from evidence only, and return JSON in the required format. +Do not anchor to target thresholds. When evidence is mixed, score lower and +explain uncertainty. +``` + +See your editor's overlay section below for the agent config format. + +### Plan commands + +```bash +desloppify plan reorder top # move all cluster members at once +desloppify plan reorder top # mix clusters + findings in one reorder +desloppify plan reorder before -t X # position relative to another item/cluster +desloppify plan cluster reorder a,b top # reorder multiple clusters as one block +desloppify plan resolve # mark complete +desloppify plan reopen # reopen +desloppify backlog # broader non-execution backlog +``` + +### Commit tracking + +```bash +desloppify plan commit-log # see uncommitted + committed status +desloppify plan commit-log record # record HEAD commit, update PR description +desloppify plan commit-log record --note "why" # with rationale +desloppify plan commit-log record --only "smells::*" # record specific findings only +desloppify plan commit-log history # show commit records +desloppify plan commit-log pr # preview PR body markdown +desloppify config set commit_tracking_enabled false # disable guidance +``` + +After resolving findings as `fixed`, the tool shows uncommitted work, committed history, and a suggested commit message. After committing externally, run `record` to move findings from uncommitted to committed and auto-update the linked PR description. + +### Quick reference + +```bash +desloppify next --count 5 # top 5 execution items +desloppify next --cluster # drill into a cluster +desloppify backlog --count 5 # top 5 backlog items outside execution +desloppify show # filter by file/detector/ID +desloppify show --status open # all open findings +desloppify plan skip --permanent "" --note "reason" --attest "..." # accept debt +desloppify exclude # exclude a directory from scanning +desloppify config show # show all config including excludes +desloppify scan --path . --reset-subjective # reset subjective baseline to 0 +``` + +## 4. Fix Tool Issues Upstream + +When desloppify itself appears wrong or inconsistent — a bug, a bad detection, a crash, confusing output — **fix it and open a PR**. If you can't confidently fix it, file an issue instead. + +### Fix and PR (preferred) + +Clone the tool repo to a temp directory, make the fix there, and verify it works against the project you're scanning before pushing. + +```bash +git clone https://github.com/peteromallet/desloppify.git /tmp/desloppify-fix +cd /tmp/desloppify-fix +git checkout -b fix/ +``` + +Make your changes, then run the test suite and verify the fix against the original project: + +```bash +python -m pytest desloppify/tests/ -q +python -m desloppify scan --path # the project you were scanning +``` + +Once it looks good, push and open a PR: + +```bash +git add && git commit -m "fix: " +git push -u origin fix/ +gh pr create --title "fix: " --body "$(cat <<'EOF' +## Problem + + +## Fix + +EOF +)" +``` + +Clean up after: `rm -rf /tmp/desloppify-fix` + +### File an issue (fallback) + +If the fix is unclear or the change needs discussion, open an issue at `https://github.com/peteromallet/desloppify/issues` with a minimal repro: command, path, expected output, actual output. + +## Prerequisite + +`command -v desloppify >/dev/null 2>&1 && echo "desloppify: installed" || echo "NOT INSTALLED — run: pip install --upgrade git+https://github.com/peteromallet/desloppify.git"` + + + +## Claude Code Overlay + +Use Claude subagents for subjective scoring work. **Do not use `--runner codex`** — use Claude subagents exclusively. + +### Review workflow + +Run `desloppify review --prepare` first to generate review data, then use Claude subagents: + +1. **Prepare**: `desloppify review --prepare` — writes `query.json` and `.desloppify/review_packet_blind.json`. +2. **Launch subagents**: Split the review across N parallel Claude subagents (one message, multiple Task calls). Each agent reviews a subset of dimensions. +3. **Merge & import**: Merge agent outputs, then `desloppify review --import merged.json --manual-override --attest "Claude subagents ran blind reviews against review_packet_blind.json" --scan-after-import`. + +#### How to split dimensions across subagents + +- Read `dimension_prompts` from `query.json` for dimensions with definitions and seed files. +- Read `.desloppify/review_packet_blind.json` for the blind packet (no score targets, no anchoring data). +- Group dimensions into 3-4 batches by theme (e.g., architecture, code quality, testing, conventions). +- Launch one Task agent per batch with `subagent_type: "general-purpose"`. Each agent gets: + - The codebase path and list of dimensions to score + - The blind packet path to read + - Instruction to score from code evidence only, not from targets +- Each agent writes output to a separate file. Merge assessments (average overlapping dimension scores) and concatenate findings. + +### Subagent rules + +1. Each agent must be context-isolated — do not pass conversation history or score targets. +2. Agents must consume `.desloppify/review_packet_blind.json` (not full `query.json`) to avoid score anchoring. + +### Triage workflow + +Orchestrate triage with per-stage subagents: +1. `desloppify plan triage --run-stages --runner claude` — prints orchestrator instructions +2. For each stage (observe → reflect → organize → enrich): + - Get prompt: `desloppify plan triage --stage-prompt ` + - Launch a subagent with that prompt + - Verify: `desloppify plan triage` (check dashboard) + - Confirm: `desloppify plan triage --confirm --attestation "..."` +3. Complete: `desloppify plan triage --complete --strategy "..." --attestation "..."` + + + diff --git a/.gitignore b/.gitignore index d5cd6bc..cf4a39b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,7 @@ data/*.db # Environment files .env + +# Desloppify local state +.desloppify/ +scorecard.png diff --git a/gui.py b/gui.py index 05b40b6..f982e9d 100755 --- a/gui.py +++ b/gui.py @@ -753,8 +753,13 @@ def _fetch_worker(): self.root.after( 0, lambda: self._on_provider_fetch_complete(trades, provider_name) ) - except Exception as e: - self.root.after(0, lambda: self._on_api_fetch_error(str(e))) + except Exception as exc: + try: + self.root.after(0, lambda err=str(exc): self._on_api_fetch_error(err)) + except Exception: + # root may be destroyed (user closed GUI during fetch); + # ensure the lock flag is cleared so it doesn't stick + self._fetch_in_progress = False thread = threading.Thread(target=_fetch_worker, daemon=True) thread.start() @@ -780,8 +785,8 @@ def _on_api_fetch_complete(self, raw_trades): import tempfile with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: - json.dump(raw_trades, tmp) tmp_path = tmp.name + json.dump(raw_trades, tmp) try: self.all_trades = load_trades(tmp_path) @@ -1810,7 +1815,7 @@ def show_about(self): def main(): """Main entry point for GUI application""" root = tk.Tk() - app = PredictionAnalyzerGUI(root) + PredictionAnalyzerGUI(root) root.mainloop() diff --git a/prediction_analyzer/api/dependencies.py b/prediction_analyzer/api/dependencies.py index a9a8b50..5ebc594 100644 --- a/prediction_analyzer/api/dependencies.py +++ b/prediction_analyzer/api/dependencies.py @@ -56,12 +56,3 @@ async def get_current_user( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account") return user - - -async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: - """ - Dependency that ensures the current user is active. - """ - if not current_user.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account") - return current_user diff --git a/prediction_analyzer/api/routers/auth.py b/prediction_analyzer/api/routers/auth.py index 8999067..3e3d384 100644 --- a/prediction_analyzer/api/routers/auth.py +++ b/prediction_analyzer/api/routers/auth.py @@ -10,13 +10,13 @@ from ..dependencies import get_db from ..schemas.user import UserCreate, UserResponse -from ..schemas.auth import Token +from ..schemas.auth import Token, SignupResponse from ..services.auth_service import auth_service router = APIRouter(prefix="/auth", tags=["authentication"]) -@router.post("/signup", response_model=dict, status_code=status.HTTP_201_CREATED) +@router.post("/signup", response_model=SignupResponse, status_code=status.HTTP_201_CREATED) async def signup(user_data: UserCreate, db: Session = Depends(get_db)): """ Register a new user account. diff --git a/prediction_analyzer/api/schemas/analysis.py b/prediction_analyzer/api/schemas/analysis.py index aa0da3c..1f3d99a 100644 --- a/prediction_analyzer/api/schemas/analysis.py +++ b/prediction_analyzer/api/schemas/analysis.py @@ -3,18 +3,20 @@ Analysis-related Pydantic schemas """ -from pydantic import BaseModel +from pydantic import BaseModel, Field from datetime import datetime from typing import Optional, List, Dict, Any class FilterParams(BaseModel): - """Parameters for filtering trades""" + """Parameters for filtering trades.""" start_date: Optional[str] = None # YYYY-MM-DD format end_date: Optional[str] = None - types: Optional[List[str]] = None # ["Buy", "Sell"] + trade_types: Optional[List[str]] = Field(None, alias="types") # ["Buy", "Sell"] sides: Optional[List[str]] = None # ["YES", "NO"] + + model_config = {"populate_by_name": True} min_pnl: Optional[float] = None max_pnl: Optional[float] = None market_slug: Optional[str] = None diff --git a/prediction_analyzer/api/schemas/auth.py b/prediction_analyzer/api/schemas/auth.py index 08aa212..366adcd 100644 --- a/prediction_analyzer/api/schemas/auth.py +++ b/prediction_analyzer/api/schemas/auth.py @@ -1,20 +1,29 @@ # prediction_analyzer/api/schemas/auth.py -""" -Authentication-related Pydantic schemas -""" +"""Authentication-related Pydantic schemas.""" from pydantic import BaseModel from typing import Optional +from .user import UserResponse + class Token(BaseModel): - """JWT token response""" + """JWT token response.""" access_token: str token_type: str = "bearer" class TokenData(BaseModel): - """Data extracted from JWT token""" + """Data extracted from JWT token.""" user_id: Optional[int] = None + + +class SignupResponse(BaseModel): + """Response returned after successful user registration.""" + + user: UserResponse + access_token: str + token_type: str = "bearer" + message: str diff --git a/prediction_analyzer/api/services/analysis_service.py b/prediction_analyzer/api/services/analysis_service.py index 78ed8f3..30df0af 100644 --- a/prediction_analyzer/api/services/analysis_service.py +++ b/prediction_analyzer/api/services/analysis_service.py @@ -1,7 +1,5 @@ # prediction_analyzer/api/services/analysis_service.py -""" -Analysis service - wraps existing pnl.py and filters.py functions -""" +"""Analysis service — wraps existing pnl.py and filters.py functions.""" import json from typing import List, Dict, Any, Optional @@ -23,26 +21,17 @@ class AnalysisService: - """Service for running analyses on trade data""" + """Service for running analyses on trade data.""" def apply_filters( self, trades: List[TradeDataclass], filters: FilterParams ) -> List[TradeDataclass]: - """ - Apply filter parameters to a list of trades - - Args: - trades: List of Trade dataclass objects - filters: Filter parameters - - Returns: - Filtered list of trades - """ + """Apply filter parameters to a list of trades.""" if filters.start_date or filters.end_date: trades = filter_by_date(trades, filters.start_date, filters.end_date) - if filters.types: - trades = filter_by_trade_type(trades, filters.types) + if filters.trade_types: + trades = filter_by_trade_type(trades, filters.trade_types) if filters.sides: trades = filter_by_side(trades, filters.sides) @@ -58,44 +47,19 @@ def apply_filters( def get_global_summary( self, db: Session, user_id: int, filters: Optional[FilterParams] = None ) -> Dict[str, Any]: - """ - Calculate global PnL summary for a user - - Args: - db: Database session - user_id: User ID - filters: Optional filter parameters - - Returns: - Dictionary with global summary statistics - """ - # Get all user trades + """Calculate global PnL summary for a user.""" db_trades = trade_service.get_all_user_trades(db, user_id) trades = trade_service.db_trades_to_dataclass(db_trades) - # Apply filters if provided if filters: trades = self.apply_filters(trades, filters) - # Use existing pnl.py function return calculate_global_pnl_summary(trades) def get_market_summary( self, db: Session, user_id: int, market_slug: str, filters: Optional[FilterParams] = None ) -> Dict[str, Any]: - """ - Calculate PnL summary for a specific market - - Args: - db: Database session - user_id: User ID - market_slug: Market identifier - filters: Optional filter parameters - - Returns: - Dictionary with market summary statistics - """ - # Get trades for specific market + """Calculate PnL summary for a specific market.""" db_trades = ( db.query(TradeModel) .filter(TradeModel.user_id == user_id, TradeModel.market_slug == market_slug) @@ -105,11 +69,9 @@ def get_market_summary( trades = trade_service.db_trades_to_dataclass(db_trades) - # Apply filters if provided if filters: trades = self.apply_filters(trades, filters) - # Use existing pnl.py function summary = calculate_market_pnl_summary(trades) summary["market_slug"] = market_slug return summary @@ -117,12 +79,7 @@ def get_market_summary( def get_market_breakdown( self, db: Session, user_id: int, filters: Optional[FilterParams] = None ) -> Dict[str, Dict]: - """ - Get PnL breakdown by market - - Returns: - Dictionary mapping market_slug to statistics - """ + """Get PnL breakdown by market.""" db_trades = trade_service.get_all_user_trades(db, user_id) trades = trade_service.db_trades_to_dataclass(db_trades) @@ -138,12 +95,7 @@ def get_pnl_timeseries( market_slug: Optional[str] = None, filters: Optional[FilterParams] = None, ) -> List[Dict]: - """ - Get time-series PnL data for charting - - Returns: - List of dictionaries with timestamp, cumulative_pnl, exposure - """ + """Get time-series PnL data for charting.""" if market_slug: db_trades = ( db.query(TradeModel) @@ -162,9 +114,7 @@ def get_pnl_timeseries( if not trades: return [] - # Use existing calculate_pnl function df = calculate_pnl(trades) - return df.to_dict(orient="records") # Saved Analysis CRUD @@ -172,7 +122,7 @@ def get_pnl_timeseries( def save_analysis( self, db: Session, user_id: int, analysis_data: SavedAnalysisCreate ) -> SavedAnalysis: - """Save an analysis result""" + """Save an analysis result.""" saved = SavedAnalysis( user_id=user_id, name=analysis_data.name, @@ -189,7 +139,7 @@ def save_analysis( return saved def get_saved_analyses(self, db: Session, user_id: int) -> List[SavedAnalysis]: - """Get all saved analyses for a user""" + """Get all saved analyses for a user.""" return ( db.query(SavedAnalysis) .filter(SavedAnalysis.user_id == user_id) @@ -200,7 +150,7 @@ def get_saved_analyses(self, db: Session, user_id: int) -> List[SavedAnalysis]: def get_saved_analysis( self, db: Session, user_id: int, analysis_id: int ) -> Optional[SavedAnalysis]: - """Get a specific saved analysis""" + """Get a specific saved analysis.""" return ( db.query(SavedAnalysis) .filter(SavedAnalysis.id == analysis_id, SavedAnalysis.user_id == user_id) @@ -208,24 +158,14 @@ def get_saved_analysis( ) def delete_saved_analysis(self, db: Session, analysis: SavedAnalysis) -> None: - """Delete a saved analysis""" + """Delete a saved analysis.""" db.delete(analysis) db.commit() def get_filtered_trades( self, db: Session, user_id: int, filters: Optional[FilterParams] = None ) -> List[TradeDataclass]: - """ - Get all trades for a user with optional filters applied. - - Args: - db: Database session - user_id: User ID - filters: Optional filter parameters - - Returns: - List of Trade dataclass objects - """ + """Get all trades for a user with optional filters applied.""" db_trades = trade_service.get_all_user_trades(db, user_id) trades = trade_service.db_trades_to_dataclass(db_trades) @@ -235,7 +175,7 @@ def get_filtered_trades( return trades def parse_saved_analysis(self, analysis: SavedAnalysis) -> Dict[str, Any]: - """Parse a saved analysis into response format""" + """Parse a saved analysis into response format.""" return { "id": analysis.id, "name": analysis.name, diff --git a/prediction_analyzer/charts/enhanced.py b/prediction_analyzer/charts/enhanced.py index 91eb2a4..2179f1a 100644 --- a/prediction_analyzer/charts/enhanced.py +++ b/prediction_analyzer/charts/enhanced.py @@ -9,7 +9,8 @@ from plotly.subplots import make_subplots from pathlib import Path from typing import List, Optional -from ..trade_loader import Trade, _sanitize_filename +from ..trade_loader import Trade +from ..utils.export import sanitize_filename as _sanitize_filename from ..exceptions import NoTradesError logger = logging.getLogger(__name__) diff --git a/prediction_analyzer/charts/pro.py b/prediction_analyzer/charts/pro.py index 2c240c6..445bb7b 100644 --- a/prediction_analyzer/charts/pro.py +++ b/prediction_analyzer/charts/pro.py @@ -4,11 +4,13 @@ """ import logging +from decimal import Decimal import plotly.graph_objects as go from plotly.subplots import make_subplots from pathlib import Path from typing import List, Optional -from ..trade_loader import Trade, _sanitize_filename +from ..trade_loader import Trade +from ..utils.export import sanitize_filename as _sanitize_filename from ..exceptions import NoTradesError logger = logging.getLogger(__name__) @@ -45,12 +47,12 @@ def generate_pro_chart( types = [t.type for t in sorted_trades] sides = [t.side for t in sorted_trades] - # Calculate cumulative PnL + # Calculate cumulative PnL using Decimal to avoid float drift cumulative_pnl = [] - total = 0 + total = Decimal("0") for pnl in pnls: - total += pnl - cumulative_pnl.append(total) + total += Decimal(str(pnl)) + cumulative_pnl.append(float(total)) # Calculate exposure net_exposure = [] diff --git a/prediction_analyzer/charts/simple.py b/prediction_analyzer/charts/simple.py index 3058fb0..b00f33e 100644 --- a/prediction_analyzer/charts/simple.py +++ b/prediction_analyzer/charts/simple.py @@ -8,7 +8,8 @@ import matplotlib.dates as mdates from pathlib import Path from typing import List, Optional -from ..trade_loader import Trade, _sanitize_filename +from ..trade_loader import Trade +from ..utils.export import sanitize_filename as _sanitize_filename from ..config import get_trade_style from ..exceptions import NoTradesError diff --git a/prediction_analyzer/filters.py b/prediction_analyzer/filters.py index 2ba6977..1350c03 100644 --- a/prediction_analyzer/filters.py +++ b/prediction_analyzer/filters.py @@ -5,21 +5,12 @@ import math from datetime import datetime, timedelta, timezone -from typing import List, Optional +from typing import Any, List, Optional, Union from .trade_loader import Trade -def _normalize_datetime(dt) -> Optional[datetime]: - """ - Normalize a datetime value to a naive datetime for consistent comparison. - Handles both timezone-aware and naive datetimes, and numeric timestamps. - - Args: - dt: datetime object, pandas Timestamp, or numeric timestamp - - Returns: - Naive datetime object - """ +def _normalize_datetime(dt: Union[datetime, int, float, Any, None]) -> Optional[datetime]: + """Normalize a datetime value to a naive UTC datetime for consistent comparison.""" if dt is None: return None diff --git a/prediction_analyzer/pnl.py b/prediction_analyzer/pnl.py index fdf6652..bec9698 100644 --- a/prediction_analyzer/pnl.py +++ b/prediction_analyzer/pnl.py @@ -97,7 +97,7 @@ def _summarize_trades(trades: List[Trade]) -> Dict: "total_pnl": total_pnl, "win_rate": win_rate, "avg_pnl_per_trade": total_pnl / total_trades if total_trades > 0 else 0, - "avg_pnl": total_pnl / total_trades if total_trades > 0 else 0, + "avg_pnl": total_pnl / total_trades if total_trades > 0 else 0, # alias for avg_pnl_per_trade "winning_trades": winning_trades, "losing_trades": losing_trades, "breakeven_trades": breakeven_trades, diff --git a/prediction_analyzer/providers/kalshi.py b/prediction_analyzer/providers/kalshi.py index 3d9c8da..f88eac3 100644 --- a/prediction_analyzer/providers/kalshi.py +++ b/prediction_analyzer/providers/kalshi.py @@ -19,7 +19,8 @@ from typing import List, Optional, Dict, Any from .base import MarketProvider -from ..trade_loader import Trade, _parse_timestamp, sanitize_numeric +from ..trade_loader import Trade, sanitize_numeric +from ..utils.time_utils import parse_timestamp as _parse_timestamp logger = logging.getLogger(__name__) @@ -199,6 +200,8 @@ def _apply_position_pnl(trades: List[Trade], pnl_map: Dict[str, float]): for t in trades: if t.type.lower() in KalshiProvider._SELL_TYPES and t.market_slug in pnl_map: + if t.pnl_is_set: + continue # Never overwrite provider-set PnL (invariant #2) total = sell_shares[t.market_slug] if total > 0: t.pnl = pnl_map[t.market_slug] * (t.shares / total) diff --git a/prediction_analyzer/providers/limitless.py b/prediction_analyzer/providers/limitless.py index 226895e..d00cf90 100644 --- a/prediction_analyzer/providers/limitless.py +++ b/prediction_analyzer/providers/limitless.py @@ -8,7 +8,8 @@ from typing import List, Optional, Dict, Any from .base import MarketProvider -from ..trade_loader import Trade, _parse_timestamp +from ..trade_loader import Trade +from ..utils.time_utils import parse_timestamp as _parse_timestamp logger = logging.getLogger(__name__) diff --git a/prediction_analyzer/providers/manifold.py b/prediction_analyzer/providers/manifold.py index 4809949..c624ad1 100644 --- a/prediction_analyzer/providers/manifold.py +++ b/prediction_analyzer/providers/manifold.py @@ -13,7 +13,8 @@ from typing import List, Optional, Dict, Any from .base import MarketProvider -from ..trade_loader import Trade, _parse_timestamp +from ..trade_loader import Trade +from ..utils.time_utils import parse_timestamp as _parse_timestamp logger = logging.getLogger(__name__) diff --git a/prediction_analyzer/providers/pnl_calculator.py b/prediction_analyzer/providers/pnl_calculator.py index de5a263..9723719 100644 --- a/prediction_analyzer/providers/pnl_calculator.py +++ b/prediction_analyzer/providers/pnl_calculator.py @@ -18,13 +18,8 @@ def compute_realized_pnl(trades: List[Trade]) -> List[Trade]: """Compute realized PnL from buy/sell pairs per market+side using FIFO matching. Modifies and returns the same Trade objects with updated pnl fields. - Only updates trades whose pnl is currently 0.0. - - Args: - trades: List of trades (may be unsorted). - - Returns: - Same list with pnl field updated on sell trades. + Only updates trades where pnl_is_set is False (provider did not supply PnL). + Provider-set PnL — including legitimate zero/breakeven — is never overwritten. """ # Skip if there are no sell trades needing PnL computation. # We cannot use `all(t.pnl != 0.0)` because legitimate zero-PnL @@ -43,7 +38,13 @@ def compute_realized_pnl(trades: List[Trade]) -> List[Trade]: is_sell = trade.type.lower() in ("sell", "market sell", "limit sell") if is_buy: - buy_queues[key].append([Decimal(str(trade.price)), Decimal(str(trade.shares))]) + # Use cost/shares (actual cost basis including fees) rather than + # the market reference price, so FIFO PnL reflects real cash flows. + shares_d = Decimal(str(trade.shares)) + cost_per_share = ( + Decimal(str(trade.cost)) / shares_d if shares_d else Decimal(str(trade.price)) + ) + buy_queues[key].append([cost_per_share, shares_d]) elif is_sell: # Always consume the buy queue to keep FIFO state correct, # even when trade already has a PnL value from the provider. @@ -72,7 +73,14 @@ def compute_realized_pnl(trades: List[Trade]) -> List[Trade]: # Only set PnL if trade doesn't already have one from the provider if not trade.pnl_is_set: matched_shares = Decimal(str(trade.shares)) - remaining - sell_revenue = matched_shares * Decimal(str(trade.price)) + # Use cost/shares (actual proceeds per share) for sell revenue + sell_shares_d = Decimal(str(trade.shares)) + proceeds_per_share = ( + Decimal(str(trade.cost)) / sell_shares_d + if sell_shares_d + else Decimal(str(trade.price)) + ) + sell_revenue = matched_shares * proceeds_per_share trade.pnl = float(sell_revenue - total_buy_cost) trade.pnl_is_set = True diff --git a/prediction_analyzer/providers/polymarket.py b/prediction_analyzer/providers/polymarket.py index f1c8831..134f864 100644 --- a/prediction_analyzer/providers/polymarket.py +++ b/prediction_analyzer/providers/polymarket.py @@ -16,7 +16,8 @@ from typing import List, Optional, Dict, Any from .base import MarketProvider -from ..trade_loader import Trade, _parse_timestamp +from ..trade_loader import Trade +from ..utils.time_utils import parse_timestamp as _parse_timestamp logger = logging.getLogger(__name__) diff --git a/prediction_analyzer/reporting/report_data.py b/prediction_analyzer/reporting/report_data.py index 1fb4c49..9b0f154 100644 --- a/prediction_analyzer/reporting/report_data.py +++ b/prediction_analyzer/reporting/report_data.py @@ -1,93 +1,72 @@ # prediction_analyzer/reporting/report_data.py -""" -Data export functionality (CSV, Excel, JSON) -""" +"""Data export functionality (CSV, Excel, JSON).""" +import json import logging +from typing import Callable, List + import pandas as pd -from typing import List + from ..trade_loader import Trade from ..exceptions import NoTradesError, ExportError logger = logging.getLogger(__name__) -def export_to_csv(trades: List[Trade], filename: str = "trades_export.csv"): - """ - Export trades to CSV file - - Args: - trades: List of Trade objects - filename: Output CSV filename - """ +def _export_with_logging( + export_fn: Callable[[List[Trade], str], None], + trades: List[Trade], + filename: str, + fmt_name: str, +) -> bool: + """Guard, execute, and log an export operation.""" if not trades: raise NoTradesError("No trades to export.") - try: - df = pd.DataFrame([t.to_dict() for t in trades]) - df.to_csv(filename, index=False) + export_fn(trades, filename) logger.info("Trades exported to: %s", filename) return True + except (NoTradesError, ExportError): + raise except Exception as e: - logger.error("Error exporting to CSV: %s", e) - raise ExportError(f"Error exporting to CSV {filename}: {e}") from e - + logger.error("Error exporting to %s: %s", fmt_name, e) + raise ExportError(f"Error exporting to {fmt_name} {filename}: {e}") from e -def export_to_excel(trades: List[Trade], filename: str = "trades_export.xlsx"): - """ - Export trades to Excel file with multiple sheets - - Args: - trades: List of Trade objects - filename: Output Excel filename - """ - if not trades: - raise NoTradesError("No trades to export.") - try: - df = pd.DataFrame([t.to_dict() for t in trades]) +def _write_csv(trades: List[Trade], filename: str) -> None: + df = pd.DataFrame([t.to_dict() for t in trades]) + df.to_csv(filename, index=False) - with pd.ExcelWriter(filename, engine="openpyxl") as writer: - # Main trades sheet - df.to_excel(writer, sheet_name="All Trades", index=False) - # Summary by market (group by slug for consistency with pnl.py) - summary = ( - df.groupby("market_slug") - .agg({"cost": "sum", "pnl": "sum", "market": "first"}) - .rename(columns={"market": "market_name"}) - ) - summary["trade_count"] = df.groupby("market_slug").size() - summary.to_excel(writer, sheet_name="Market Summary") +def _write_excel(trades: List[Trade], filename: str) -> None: + df = pd.DataFrame([t.to_dict() for t in trades]) + with pd.ExcelWriter(filename, engine="openpyxl") as writer: + df.to_excel(writer, sheet_name="All Trades", index=False) + summary = ( + df.groupby("market_slug") + .agg({"cost": "sum", "pnl": "sum", "market": "first"}) + .rename(columns={"market": "market_name"}) + ) + summary["trade_count"] = df.groupby("market_slug").size() + summary.to_excel(writer, sheet_name="Market Summary") - logger.info("Trades exported to: %s", filename) - return True - except Exception as e: - logger.error("Error exporting to Excel: %s", e) - raise ExportError(f"Error exporting to Excel {filename}: {e}") from e +def _write_json(trades: List[Trade], filename: str) -> None: + trades_dict = [t.to_dict() for t in trades] + with open(filename, "w", encoding="utf-8") as f: + json.dump(trades_dict, f, indent=2) -def export_to_json(trades: List[Trade], filename: str = "trades_export.json"): - """ - Export trades to JSON file - Args: - trades: List of Trade objects - filename: Output JSON filename - """ - import json +def export_to_csv(trades: List[Trade], filename: str = "trades_export.csv"): + """Export trades to CSV file.""" + return _export_with_logging(_write_csv, trades, filename, "CSV") - if not trades: - raise NoTradesError("No trades to export.") - try: - trades_dict = [t.to_dict() for t in trades] +def export_to_excel(trades: List[Trade], filename: str = "trades_export.xlsx"): + """Export trades to Excel file with multiple sheets.""" + return _export_with_logging(_write_excel, trades, filename, "Excel") - with open(filename, "w", encoding="utf-8") as f: - json.dump(trades_dict, f, indent=2) - logger.info("Trades exported to: %s", filename) - return True - except Exception as e: - logger.error("Error exporting to JSON: %s", e) - raise ExportError(f"Error exporting to JSON {filename}: {e}") from e +def export_to_json(trades: List[Trade], filename: str = "trades_export.json"): + """Export trades to JSON file.""" + return _export_with_logging(_write_json, trades, filename, "JSON") diff --git a/prediction_analyzer/trade_loader.py b/prediction_analyzer/trade_loader.py index 5fcdc9d..18ca03c 100644 --- a/prediction_analyzer/trade_loader.py +++ b/prediction_analyzer/trade_loader.py @@ -5,14 +5,18 @@ import json import logging -import pandas as pd -from dataclasses import dataclass -from typing import List, Union, Optional, Dict, Any -from datetime import datetime, timezone import math import re +from dataclasses import dataclass +from datetime import datetime, timezone +from decimal import Decimal +from typing import List, Union, Optional, Dict, Any + +import pandas as pd from .exceptions import TradeLoadError +from .utils.time_utils import parse_timestamp as _parse_timestamp # noqa: F401 +from .utils.export import sanitize_filename as _sanitize_filename # noqa: F401 logger = logging.getLogger(__name__) @@ -21,29 +25,20 @@ INF_CAP = 999999.99 -def sanitize_numeric(value) -> float: - """ - Guard against NaN/Infinity in numeric values for JSON serialization. - - Args: - value: A numeric value (float or Decimal) that may be NaN or Infinity - - Returns: - A safe float value (0.0 for NaN, capped for Infinity) - """ +def sanitize_numeric(value: Union[float, Decimal, int]) -> float: + """Guard against NaN/Infinity in numeric values for JSON serialization.""" if isinstance(value, float): if math.isnan(value): return 0.0 if math.isinf(value): return INF_CAP if value > 0 else -INF_CAP - # Handle Decimal NaN/Infinity elif hasattr(value, "is_nan"): if value.is_nan(): return 0.0 if value.is_infinite(): return INF_CAP if value > 0 else -INF_CAP return float(value) - return value + return float(value) @dataclass @@ -89,107 +84,6 @@ def to_dict(self) -> Dict[str, Any]: } -def _parse_timestamp(value) -> datetime: - """ - Parse timestamp from various formats (Unix epoch, RFC 3339 string, etc.) - - Args: - value: Timestamp value (int, float, or string) - - Returns: - datetime object (timezone-naive in UTC) - """ - if value is None or (isinstance(value, (int, float)) and value == 0): - return datetime(1970, 1, 1) - - # If it's already a datetime, convert to naive UTC - if isinstance(value, datetime): - if value.tzinfo is not None: - return value.astimezone(timezone.utc).replace(tzinfo=None) - return value - - # If it's a pandas Timestamp - if hasattr(value, "to_pydatetime"): - dt = value.to_pydatetime() - if dt.tzinfo is not None: - return dt.astimezone(timezone.utc).replace(tzinfo=None) - return dt - - # Try to parse as string first (RFC 3339, ISO 8601) - if isinstance(value, str): - try: - # Handle RFC 3339/ISO 8601 format (e.g., "2024-01-15T10:30:00Z") - # Replace 'Z' with '+00:00' for fromisoformat compatibility - clean_value = value.replace("Z", "+00:00") - dt = datetime.fromisoformat(clean_value) - # Convert to naive UTC - if dt.tzinfo is not None: - dt = dt.astimezone(timezone.utc).replace(tzinfo=None) - return dt - except ValueError: - pass - - # Try parsing as numeric string - try: - numeric_value = float(value) - # If it's a large number, assume milliseconds - if numeric_value > 1e12: - return datetime.fromtimestamp(numeric_value / 1000, tz=timezone.utc).replace( - tzinfo=None - ) - return datetime.fromtimestamp(numeric_value, tz=timezone.utc).replace(tzinfo=None) - except ValueError: - pass - - # Handle numeric timestamps - if isinstance(value, (int, float)): - # If it's a very large number, assume milliseconds - if value > 1e12: - return datetime.fromtimestamp(value / 1000, tz=timezone.utc).replace(tzinfo=None) - return datetime.fromtimestamp(value, tz=timezone.utc).replace(tzinfo=None) - - # Fallback: try pandas parsing - try: - result = pd.to_datetime(value, utc=True) - if hasattr(result, "to_pydatetime"): - dt = result.to_pydatetime() - if dt.tzinfo is not None: - return dt.astimezone(timezone.utc).replace(tzinfo=None) - return dt - return result - except Exception: - logger.warning("Could not parse timestamp value %r; defaulting to epoch", value) - return datetime(1970, 1, 1) - - -def _sanitize_filename(name: str, max_length: int = 50) -> str: - """ - Sanitize a string for use in filenames. - - Args: - name: The original name - max_length: Maximum length for the sanitized name - - Returns: - Sanitized filename-safe string - """ - # Remove or replace characters that are invalid in filenames - # Invalid on Windows: < > : " / \ | ? * - # Also remove control characters and other problematic chars - sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name) - # Replace multiple underscores with single - sanitized = re.sub(r"_+", "_", sanitized) - # Remove leading/trailing underscores and spaces - sanitized = sanitized.strip("_ ") - # Truncate to max length - if len(sanitized) > max_length: - sanitized = sanitized[:max_length].rstrip("_") - # Ensure we have something - if not sanitized: - sanitized = "unnamed" - return sanitized - - def load_trades(file_path: str) -> List[Trade]: """ Load trades from JSON, CSV, or XLSX file diff --git a/prediction_analyzer/utils/data.py b/prediction_analyzer/utils/data.py index 3f8976e..f471fcd 100644 --- a/prediction_analyzer/utils/data.py +++ b/prediction_analyzer/utils/data.py @@ -13,16 +13,7 @@ def fetch_trade_history(api_key: str, page_limit: int = 100) -> List[dict]: - """ - Fetch trade history from the Limitless Exchange API. - - Args: - api_key: Limitless API key (lmts_...) - page_limit: Number of trades per page - - Returns: - List of trade dictionaries - """ + """Fetch trade history from the Limitless Exchange API.""" all_trades = [] page = 1 headers = get_auth_headers(api_key) @@ -59,20 +50,12 @@ def fetch_trade_history(api_key: str, page_limit: int = 100) -> List[dict]: def fetch_market_details(market_slug: str): - """ - Fetch live market details from API (public endpoint, no auth required). - - Args: - market_slug: Market slug identifier - - Returns: - Market data dictionary or None on error - """ + """Fetch live market details from API (public endpoint, no auth required).""" url = f"{API_BASE_URL}/markets/{market_slug}" try: resp = requests.get(url, timeout=10) if resp.status_code == 200: return resp.json() - except Exception: - pass + except requests.RequestException as exc: + logger.debug("Failed to fetch market details for %s: %s", market_slug, exc) return None diff --git a/prediction_analyzer/utils/export.py b/prediction_analyzer/utils/export.py index 72ee748..b8984ce 100644 --- a/prediction_analyzer/utils/export.py +++ b/prediction_analyzer/utils/export.py @@ -1,25 +1,31 @@ # prediction_analyzer/utils/export.py -""" -Export utility functions -""" +"""Export utility functions.""" import logging -import matplotlib.pyplot as plt +import re from typing import Any +import matplotlib.pyplot as plt + from ..exceptions import ExportError logger = logging.getLogger(__name__) -def export_chart(fig: Any, path: str): - """ - Export a matplotlib or plotly figure to file +def sanitize_filename(name: str, max_length: int = 50) -> str: + """Sanitize a string for use in filenames (cross-platform safe).""" + sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name) + sanitized = re.sub(r"_+", "_", sanitized) + sanitized = sanitized.strip("_ ") + if len(sanitized) > max_length: + sanitized = sanitized[:max_length].rstrip("_") + if not sanitized: + sanitized = "unnamed" + return sanitized + - Args: - fig: matplotlib Figure or plotly graph object - path: Output file path - """ +def export_chart(fig: Any, path: str): + """Export a matplotlib or plotly figure to file.""" try: # Check if it's a matplotlib figure if isinstance(fig, plt.Figure): diff --git a/prediction_analyzer/utils/math_utils.py b/prediction_analyzer/utils/math_utils.py index f7d126b..168f1a8 100644 --- a/prediction_analyzer/utils/math_utils.py +++ b/prediction_analyzer/utils/math_utils.py @@ -1,68 +1,29 @@ # prediction_analyzer/utils/math_utils.py -""" -Mathematical utility functions -""" +"""Mathematical utility functions""" import numpy as np from typing import List def moving_average(values: List[float], window: int = 5) -> np.ndarray: - """ - Calculate simple moving average - - Args: - values: List of numeric values - window: Window size for moving average - - Returns: - NumPy array of moving averages - """ + """Calculate simple moving average over the given window size.""" if len(values) < window: window = len(values) return np.convolve(values, np.ones(window) / window, mode="valid") def weighted_average(values: List[float], weights: List[float]) -> float: - """ - Calculate weighted average - - Args: - values: List of numeric values - weights: List of weights (same length as values) - - Returns: - Weighted average - """ + """Calculate weighted average; values and weights must have same length.""" if len(values) != len(weights): raise ValueError("Values and weights must have same length") return np.average(values, weights=weights) def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float: - """ - Safe division that returns default value if denominator is zero - - Args: - numerator: Numerator value - denominator: Denominator value - default: Value to return if denominator is zero - - Returns: - Division result or default value - """ + """Divide numerator by denominator; return default on zero.""" return numerator / denominator if denominator != 0 else default def calculate_roi(pnl: float, investment: float) -> float: - """ - Calculate return on investment percentage - - Args: - pnl: Profit/loss amount - investment: Initial investment amount - - Returns: - ROI as percentage - """ + """Calculate return on investment as a percentage.""" return safe_divide(pnl, investment, 0.0) * 100 diff --git a/prediction_analyzer/utils/time_utils.py b/prediction_analyzer/utils/time_utils.py index e6b49a4..b0f224b 100644 --- a/prediction_analyzer/utils/time_utils.py +++ b/prediction_analyzer/utils/time_utils.py @@ -3,19 +3,88 @@ Time and date utility functions """ -from datetime import datetime, timedelta +import logging +from datetime import datetime, timedelta, timezone +from typing import Union, Any +import pandas as pd -def parse_date(date_str: str) -> datetime: - """ - Parse date string in various formats +logger = logging.getLogger(__name__) + + +def parse_timestamp(value: Union[int, float, str, "datetime", Any]) -> datetime: + """Parse timestamp from various formats into a timezone-naive UTC datetime.""" + if value is None or (isinstance(value, (int, float)) and value == 0): + return datetime(1970, 1, 1) + + # If it's already a datetime, convert to naive UTC + if isinstance(value, datetime): + if value.tzinfo is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value + + # If it's a pandas Timestamp + if hasattr(value, "to_pydatetime"): + dt = value.to_pydatetime() + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + # Try to parse as string first (RFC 3339, ISO 8601) + if isinstance(value, str): + try: + # Handle RFC 3339/ISO 8601 format (e.g., "2024-01-15T10:30:00Z") + # Replace 'Z' with '+00:00' for fromisoformat compatibility + clean_value = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(clean_value) + # Convert to naive UTC + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + except ValueError: + pass + + # Try parsing as numeric string + try: + numeric_value = float(value) + # If it's a large number, assume milliseconds + if numeric_value > 1e12: + return datetime.fromtimestamp( + numeric_value / 1000, tz=timezone.utc + ).replace(tzinfo=None) + return datetime.fromtimestamp(numeric_value, tz=timezone.utc).replace( + tzinfo=None + ) + except ValueError: + pass + + # Handle numeric timestamps + if isinstance(value, (int, float)): + # If it's a very large number, assume milliseconds + if value > 1e12: + return datetime.fromtimestamp(value / 1000, tz=timezone.utc).replace( + tzinfo=None + ) + return datetime.fromtimestamp(value, tz=timezone.utc).replace(tzinfo=None) - Args: - date_str: Date string (YYYY-MM-DD, YYYY/MM/DD, etc.) + # Fallback: try pandas parsing + try: + result = pd.to_datetime(value, utc=True) + if hasattr(result, "to_pydatetime"): + dt = result.to_pydatetime() + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + return result + except Exception: + logger.warning( + "Could not parse timestamp value %r; defaulting to epoch", value + ) + return datetime(1970, 1, 1) - Returns: - datetime object - """ + +def parse_date(date_str: str) -> datetime: + """Parse date string in various formats (YYYY-MM-DD, YYYY/MM/DD, etc.).""" formats = ["%Y-%m-%d", "%Y/%m/%d", "%m-%d-%Y", "%m/%d/%Y", "%Y-%m-%d %H:%M:%S"] for fmt in formats: @@ -28,29 +97,12 @@ def parse_date(date_str: str) -> datetime: def format_timestamp(timestamp: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: - """ - Format timestamp to string - - Args: - timestamp: datetime object - fmt: strftime format string - - Returns: - Formatted date string - """ + """Format timestamp to string using the given strftime format.""" return timestamp.strftime(fmt) def get_date_range(days_back: int) -> tuple: - """ - Get a date range from N days ago to now - - Args: - days_back: Number of days to go back - - Returns: - Tuple of (start_date, end_date) - """ + """Return (start_date, end_date) tuple spanning N days back to now.""" end_date = datetime.now() start_date = end_date - timedelta(days=days_back) return start_date, end_date diff --git a/prediction_mcp/tools/data_tools.py b/prediction_mcp/tools/data_tools.py index 77471b4..46d0377 100644 --- a/prediction_mcp/tools/data_tools.py +++ b/prediction_mcp/tools/data_tools.py @@ -16,7 +16,7 @@ from prediction_analyzer.exceptions import TradeLoadError, NoTradesError, InvalidFilterError from ..state import get_session -from ..errors import error_result, safe_tool +from ..errors import safe_tool from ..serializers import to_json_text, serialize_trades from ..validators import ( validate_sort_field, @@ -156,7 +156,7 @@ async def _handle_load_trades(arguments: dict): session = get_session() file_path = arguments.get("file_path") if not file_path: - return error_result(ValueError("file_path is required")).content + raise ValueError("file_path is required") # Resolve symlinks and normalize the path, then reject paths containing # ".." components (before resolution) to prevent path traversal attacks. @@ -196,7 +196,7 @@ async def _handle_fetch_trades(arguments: dict): page_limit = arguments.get("page_limit", 100) if not api_key: - return error_result(ValueError("api_key is required")).content + raise ValueError("api_key is required") from prediction_analyzer.providers import ProviderRegistry diff --git a/run_api.py b/run_api.py index 0bea477..3ca22cc 100644 --- a/run_api.py +++ b/run_api.py @@ -12,47 +12,26 @@ python run_api.py --host 0.0.0.0 # Allow external connections """ import argparse +import importlib.util import sys def check_dependencies(): """Check if required dependencies are installed""" - missing = [] - - try: - import fastapi - except ImportError: - missing.append("fastapi") - - try: - import uvicorn - except ImportError: - missing.append("uvicorn") - - try: - import sqlalchemy - except ImportError: - missing.append("sqlalchemy") - - try: - import jwt - except ImportError: - missing.append("PyJWT") - - try: - import passlib - except ImportError: - missing.append("passlib") - - try: - import argon2 - except ImportError: - missing.append("argon2-cffi") - - try: - import pydantic_settings - except ImportError: - missing.append("pydantic-settings") + required = { + "fastapi": "fastapi", + "uvicorn": "uvicorn", + "sqlalchemy": "sqlalchemy", + "jwt": "PyJWT", + "passlib": "passlib", + "argon2": "argon2-cffi", + "pydantic_settings": "pydantic-settings", + } + missing = [ + pip_name + for module, pip_name in required.items() + if importlib.util.find_spec(module) is None + ] if missing: print("Missing required dependencies:") diff --git a/run_gui.py b/run_gui.py index 40c8f7d..a4cae89 100755 --- a/run_gui.py +++ b/run_gui.py @@ -3,6 +3,7 @@ Launcher script for Prediction Analyzer GUI Checks dependencies and launches the GUI application """ +import importlib.util import sys from pathlib import Path @@ -19,22 +20,16 @@ def check_dependencies(): ] missing_packages = [] - for package in required_packages: - # Special handling for tkinter - if package == 'tkinter': + for pkg in required_packages: + if pkg == 'tkinter': + # tkinter is a C extension that may not have a proper module spec + # (find_spec returns None) even when it's installed, so use __import__ try: - __import__(package) + __import__('tkinter') except ImportError: - # tkinter might be named differently - try: - import tkinter - except ImportError: - missing_packages.append(package) - else: - try: - __import__(package) - except ImportError: - missing_packages.append(package) + missing_packages.append(pkg) + elif importlib.util.find_spec(pkg) is None: + missing_packages.append(pkg) if missing_packages: print("ERROR: Missing required dependencies!") diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 7373a96..d5cba36 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -1,7 +1,6 @@ # tests/api/test_auth.py """Tests for authentication endpoints: signup, login, token validation.""" -import pytest from .conftest import signup_user, auth_header, create_authenticated_user diff --git a/tests/api/test_security.py b/tests/api/test_security.py index 944b454..f32928f 100644 --- a/tests/api/test_security.py +++ b/tests/api/test_security.py @@ -1,7 +1,6 @@ # tests/api/test_security.py """Tests for security features: headers, rate limiting, CORS.""" -import pytest from .conftest import create_authenticated_user diff --git a/tests/api/test_trades.py b/tests/api/test_trades.py index c6ff023..4084564 100644 --- a/tests/api/test_trades.py +++ b/tests/api/test_trades.py @@ -3,9 +3,8 @@ import io import json -import pytest -from .conftest import create_authenticated_user, auth_header, signup_user +from .conftest import create_authenticated_user # Minimal valid trade file (Limitless format) _SAMPLE_TRADES_JSON = json.dumps( diff --git a/tests/conftest.py b/tests/conftest.py index 3dfceab..b2d25ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ """ import pytest -from datetime import datetime, timedelta +from datetime import datetime from typing import List from prediction_analyzer.trade_loader import Trade diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index 7486d2d..58aca78 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -4,9 +4,7 @@ """ import os -import json import pytest -import asyncio from datetime import datetime from prediction_analyzer.trade_loader import Trade diff --git a/tests/mcp/test_analysis_tools.py b/tests/mcp/test_analysis_tools.py index d2998d5..4218020 100644 --- a/tests/mcp/test_analysis_tools.py +++ b/tests/mcp/test_analysis_tools.py @@ -4,10 +4,8 @@ import json import asyncio -import pytest from prediction_mcp.tools import analysis_tools -from prediction_mcp.state import session class TestGlobalSummary: diff --git a/tests/mcp/test_chart_tools.py b/tests/mcp/test_chart_tools.py index cd68986..a30ecc6 100644 --- a/tests/mcp/test_chart_tools.py +++ b/tests/mcp/test_chart_tools.py @@ -3,11 +3,8 @@ import asyncio -import pytest from prediction_mcp.tools import chart_tools -from prediction_mcp.state import session -from .conftest import make_trades class TestGenerateChart: diff --git a/tests/mcp/test_data_tools.py b/tests/mcp/test_data_tools.py index 38caef8..37204f6 100644 --- a/tests/mcp/test_data_tools.py +++ b/tests/mcp/test_data_tools.py @@ -3,9 +3,7 @@ import json import asyncio -import os -import pytest from prediction_mcp.tools import data_tools from prediction_mcp.state import session diff --git a/tests/mcp/test_errors.py b/tests/mcp/test_errors.py index 81e044a..8700020 100644 --- a/tests/mcp/test_errors.py +++ b/tests/mcp/test_errors.py @@ -1,7 +1,6 @@ # tests/mcp/test_errors.py """Tests for MCP error handling.""" -import pytest import asyncio from prediction_analyzer.exceptions import ( diff --git a/tests/mcp/test_export_tools.py b/tests/mcp/test_export_tools.py index 80d9b9b..723e69b 100644 --- a/tests/mcp/test_export_tools.py +++ b/tests/mcp/test_export_tools.py @@ -6,10 +6,8 @@ import os import tempfile -import pytest from prediction_mcp.tools import export_tools -from prediction_mcp.state import session class TestExportTrades: diff --git a/tests/mcp/test_filter_tools.py b/tests/mcp/test_filter_tools.py index 0332af0..15d831e 100644 --- a/tests/mcp/test_filter_tools.py +++ b/tests/mcp/test_filter_tools.py @@ -4,7 +4,6 @@ import json import asyncio -import pytest from prediction_mcp.tools import filter_tools from prediction_mcp.state import session diff --git a/tests/mcp/test_llm_inputs.py b/tests/mcp/test_llm_inputs.py index 01acec1..7744c78 100644 --- a/tests/mcp/test_llm_inputs.py +++ b/tests/mcp/test_llm_inputs.py @@ -12,9 +12,7 @@ import json import asyncio -import math -import pytest from prediction_mcp.tools import ( data_tools, @@ -25,8 +23,6 @@ portfolio_tools, tax_tools, ) -from prediction_mcp.state import session -from .conftest import make_trades class TestWrongParameterNames: diff --git a/tests/mcp/test_persistence.py b/tests/mcp/test_persistence.py index 62636c2..3a49421 100644 --- a/tests/mcp/test_persistence.py +++ b/tests/mcp/test_persistence.py @@ -2,10 +2,8 @@ """Tests for SQLite session persistence.""" import os -import json import tempfile -import pytest from prediction_mcp.persistence import SessionStore from prediction_mcp.state import SessionState diff --git a/tests/mcp/test_portfolio_tools.py b/tests/mcp/test_portfolio_tools.py index 35efa9b..f9a1def 100644 --- a/tests/mcp/test_portfolio_tools.py +++ b/tests/mcp/test_portfolio_tools.py @@ -4,10 +4,8 @@ import json import asyncio -import pytest from prediction_mcp.tools import portfolio_tools -from prediction_mcp.state import session class TestOpenPositions: diff --git a/tests/mcp/test_serializers.py b/tests/mcp/test_serializers.py index 480b277..b0fc55a 100644 --- a/tests/mcp/test_serializers.py +++ b/tests/mcp/test_serializers.py @@ -1,11 +1,9 @@ # tests/mcp/test_serializers.py """Tests for MCP serializers.""" -import math import json from datetime import datetime -import pytest from prediction_analyzer.trade_loader import Trade from prediction_mcp.serializers import ( diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index 205ed45..4ff7e7c 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -3,7 +3,6 @@ import asyncio -import pytest from mcp import types from prediction_mcp.server import _TOOL_MODULES, list_tools, call_tool diff --git a/tests/mcp/test_sse_transport.py b/tests/mcp/test_sse_transport.py index f757334..7b541fd 100644 --- a/tests/mcp/test_sse_transport.py +++ b/tests/mcp/test_sse_transport.py @@ -1,7 +1,6 @@ # tests/mcp/test_sse_transport.py """Tests for HTTP/SSE transport setup.""" -import pytest from starlette.testclient import TestClient from prediction_mcp.server import create_sse_app diff --git a/tests/mcp/test_tax_tools.py b/tests/mcp/test_tax_tools.py index f0cbe20..5dad022 100644 --- a/tests/mcp/test_tax_tools.py +++ b/tests/mcp/test_tax_tools.py @@ -4,10 +4,8 @@ import json import asyncio -import pytest from prediction_mcp.tools import tax_tools -from prediction_mcp.state import session class TestTaxReport: diff --git a/tests/mcp/test_transport.py b/tests/mcp/test_transport.py index 4355407..17ad11c 100644 --- a/tests/mcp/test_transport.py +++ b/tests/mcp/test_transport.py @@ -2,18 +2,14 @@ """Tests for MCP transport safety — no stdout pollution.""" import io -import sys -import json import asyncio import contextlib -import pytest from prediction_mcp.tools import ( data_tools, analysis_tools, filter_tools, - export_tools, portfolio_tools, tax_tools, ) diff --git a/tests/mcp/test_validators.py b/tests/mcp/test_validators.py index 23ff715..1a10535 100644 --- a/tests/mcp/test_validators.py +++ b/tests/mcp/test_validators.py @@ -1,7 +1,6 @@ # tests/mcp/test_validators.py """Tests for MCP input validators.""" -import math import pytest diff --git a/tests/static_patterns/test_api_contracts.py b/tests/static_patterns/test_api_contracts.py index 3e27aad..8c3e611 100644 --- a/tests/static_patterns/test_api_contracts.py +++ b/tests/static_patterns/test_api_contracts.py @@ -6,10 +6,7 @@ remain stable. This helps catch breaking changes to the API. """ -import pytest import inspect -from typing import get_type_hints, List, Dict, Optional -from datetime import datetime class TestTradeLoaderAPIContracts: @@ -37,7 +34,7 @@ def test_save_trades_signature(self): def test_parse_timestamp_exists(self): """_parse_timestamp helper should exist.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp assert callable(_parse_timestamp) @@ -54,7 +51,6 @@ class TestPnLAPIContracts: def test_calculate_pnl_signature(self): """calculate_pnl should accept trades and return DataFrame.""" from prediction_analyzer.pnl import calculate_pnl - import pandas as pd sig = inspect.signature(calculate_pnl) params = list(sig.parameters.keys()) diff --git a/tests/static_patterns/test_config_integrity.py b/tests/static_patterns/test_config_integrity.py index 68c209c..29421c8 100644 --- a/tests/static_patterns/test_config_integrity.py +++ b/tests/static_patterns/test_config_integrity.py @@ -6,7 +6,6 @@ Invalid configuration can cause runtime errors or incorrect behavior. """ -import pytest import re diff --git a/tests/static_patterns/test_data_integrity.py b/tests/static_patterns/test_data_integrity.py index 45cd75b..d5a79f4 100644 --- a/tests/static_patterns/test_data_integrity.py +++ b/tests/static_patterns/test_data_integrity.py @@ -7,7 +7,6 @@ subtle bugs that are hard to track down. """ -import pytest import json import tempfile import os diff --git a/tests/static_patterns/test_dataclass_contracts.py b/tests/static_patterns/test_dataclass_contracts.py index 97f056f..21c687c 100644 --- a/tests/static_patterns/test_dataclass_contracts.py +++ b/tests/static_patterns/test_dataclass_contracts.py @@ -7,10 +7,8 @@ PnL calculations, and other dependent code. """ -import pytest from dataclasses import fields, is_dataclass from datetime import datetime -from typing import Optional class TestTradeDataclassStructure: diff --git a/tests/static_patterns/test_edge_cases.py b/tests/static_patterns/test_edge_cases.py index d10eea4..93d9901 100644 --- a/tests/static_patterns/test_edge_cases.py +++ b/tests/static_patterns/test_edge_cases.py @@ -7,8 +7,7 @@ """ import pytest -from datetime import datetime, timezone -import numpy as np +from datetime import datetime class TestEmptyInputHandling: @@ -113,7 +112,7 @@ class TestTimestampEdgeCases: def test_parse_timestamp_unix_epoch(self): """_parse_timestamp should handle Unix epoch timestamps.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp # Standard Unix timestamp (seconds) result = _parse_timestamp(1704067200) # 2024-01-01 00:00:00 UTC @@ -122,7 +121,7 @@ def test_parse_timestamp_unix_epoch(self): def test_parse_timestamp_milliseconds(self): """_parse_timestamp should handle millisecond timestamps.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp # Millisecond timestamp result = _parse_timestamp(1704067200000) @@ -131,7 +130,7 @@ def test_parse_timestamp_milliseconds(self): def test_parse_timestamp_iso_string(self): """_parse_timestamp should handle ISO 8601 strings.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp("2024-06-15T12:00:00Z") assert isinstance(result, datetime) @@ -140,7 +139,7 @@ def test_parse_timestamp_iso_string(self): def test_parse_timestamp_none(self): """_parse_timestamp should handle None.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp(None) assert isinstance(result, datetime) @@ -148,7 +147,7 @@ def test_parse_timestamp_none(self): def test_parse_timestamp_zero(self): """_parse_timestamp should handle zero.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp(0) assert isinstance(result, datetime) diff --git a/tests/static_patterns/test_filter_contracts.py b/tests/static_patterns/test_filter_contracts.py index d6d3e5f..f9b161d 100644 --- a/tests/static_patterns/test_filter_contracts.py +++ b/tests/static_patterns/test_filter_contracts.py @@ -10,8 +10,7 @@ 4. Handle edge cases gracefully """ -import pytest -from datetime import datetime, timedelta +from datetime import datetime class TestFilterByDateContracts: diff --git a/tests/static_patterns/test_imports.py b/tests/static_patterns/test_imports.py index 11816c7..5f3be71 100644 --- a/tests/static_patterns/test_imports.py +++ b/tests/static_patterns/test_imports.py @@ -7,7 +7,6 @@ is in a stable state. """ -import pytest import sys @@ -307,8 +306,8 @@ def test_all_modules_import_together(self): for mod in modules_to_clear: del sys.modules[mod] - # Import all modules in one test - from prediction_analyzer import ( + # Import all modules in one test to detect circular imports + from prediction_analyzer import ( # noqa: F401 trade_loader, pnl, filters, @@ -316,10 +315,16 @@ def test_all_modules_import_together(self): trade_filter, inference, ) - from prediction_analyzer.charts import simple, pro, enhanced, global_chart - from prediction_analyzer.utils import math_utils, time_utils, auth, data, export - from prediction_analyzer.reporting import report_text, report_data - from prediction_analyzer.core import interactive + from prediction_analyzer.charts import ( # noqa: F401 + simple, pro, enhanced, global_chart, + ) + from prediction_analyzer.utils import ( # noqa: F401 + math_utils, time_utils, auth, data, export, + ) + from prediction_analyzer.reporting import ( # noqa: F401 + report_text, report_data, + ) + from prediction_analyzer.core import interactive # noqa: F401 # If we get here, no circular import errors assert True diff --git a/tests/static_patterns/test_pnl_contracts.py b/tests/static_patterns/test_pnl_contracts.py index 6ab0f62..d77c44f 100644 --- a/tests/static_patterns/test_pnl_contracts.py +++ b/tests/static_patterns/test_pnl_contracts.py @@ -6,9 +6,7 @@ behavior contracts. PnL calculations must be accurate and consistent. """ -import pytest import pandas as pd -from datetime import datetime class TestCalculatePnLContracts: diff --git a/tests/static_patterns/test_utility_functions.py b/tests/static_patterns/test_utility_functions.py index 753f088..a1bac6f 100644 --- a/tests/static_patterns/test_utility_functions.py +++ b/tests/static_patterns/test_utility_functions.py @@ -9,7 +9,7 @@ import pytest import numpy as np -from datetime import datetime, timedelta +from datetime import datetime class TestMovingAverage: @@ -271,7 +271,7 @@ class TestParseTimestamp: def test_unix_seconds(self): """Unix timestamp in seconds should parse.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp # 2024-01-01 00:00:00 UTC result = _parse_timestamp(1704067200) @@ -281,7 +281,7 @@ def test_unix_seconds(self): def test_unix_milliseconds(self): """Unix timestamp in milliseconds should parse.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp(1704067200000) assert result.year == 2024 @@ -290,7 +290,7 @@ def test_unix_milliseconds(self): def test_iso_string(self): """ISO 8601 string should parse.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp("2024-06-15T12:00:00Z") assert result.year == 2024 @@ -299,7 +299,7 @@ def test_iso_string(self): def test_iso_string_with_offset(self): """ISO 8601 string with timezone offset should parse.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp("2024-06-15T12:00:00+00:00") assert result.year == 2024 @@ -307,7 +307,7 @@ def test_iso_string_with_offset(self): def test_datetime_passthrough(self): """datetime objects should pass through.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp dt = datetime(2024, 6, 15, 12, 0, 0) result = _parse_timestamp(dt) @@ -315,7 +315,7 @@ def test_datetime_passthrough(self): def test_returns_naive_datetime(self): """Result should always be timezone-naive.""" - from prediction_analyzer.trade_loader import _parse_timestamp + from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp result = _parse_timestamp("2024-06-15T12:00:00Z") assert result.tzinfo is None diff --git a/tests/test_bugfixes.py b/tests/test_bugfixes.py index 125770a..7d976b2 100644 --- a/tests/test_bugfixes.py +++ b/tests/test_bugfixes.py @@ -8,10 +8,8 @@ import json import math -import pytest import numpy as np -from datetime import datetime, timedelta -from unittest.mock import patch +from datetime import datetime from prediction_analyzer.trade_loader import Trade diff --git a/tests/test_bugfixes_audit3.py b/tests/test_bugfixes_audit3.py index 993034f..09eebbb 100644 --- a/tests/test_bugfixes_audit3.py +++ b/tests/test_bugfixes_audit3.py @@ -10,14 +10,13 @@ Bug #6: analysis_tools provider breakdown used float accumulation """ -import math from datetime import datetime from decimal import Decimal import pytest from prediction_analyzer.trade_loader import Trade, sanitize_numeric -from prediction_analyzer.positions import calculate_open_positions, calculate_concentration_risk +from prediction_analyzer.positions import calculate_open_positions from prediction_analyzer.tax import calculate_capital_gains diff --git a/tests/test_fees_wash_sales.py b/tests/test_fees_wash_sales.py index 7f0de29..956eb0f 100644 --- a/tests/test_fees_wash_sales.py +++ b/tests/test_fees_wash_sales.py @@ -10,7 +10,7 @@ from datetime import datetime from prediction_analyzer.trade_loader import Trade -from prediction_analyzer.tax import calculate_capital_gains, _detect_wash_sales +from prediction_analyzer.tax import calculate_capital_gains def _make_trade(**kwargs): @@ -733,7 +733,7 @@ def test_excess_sell_logs_warning(self, caplog): ), ] with caplog.at_level(logging.WARNING, logger="prediction_analyzer.tax"): - result = calculate_capital_gains(trades, tax_year=2024) + calculate_capital_gains(trades, tax_year=2024) assert any("no matching buy lots" in msg for msg in caplog.messages) def test_total_trades_includes_all_years(self): diff --git a/tests/test_trader_critical.py b/tests/test_trader_critical.py index 4654393..988a943 100644 --- a/tests/test_trader_critical.py +++ b/tests/test_trader_critical.py @@ -7,13 +7,13 @@ - Tax report diagnostics for unrecognized trade types """ -import json import math import pytest from datetime import datetime from unittest.mock import patch -from prediction_analyzer.trade_loader import Trade, _parse_timestamp +from prediction_analyzer.trade_loader import Trade +from prediction_analyzer.utils.time_utils import parse_timestamp as _parse_timestamp def _make_trade(**kwargs):