diff --git a/.claude/ralph/prd.json b/.claude/ralph/prd.json index 51490de..94a2166 100644 --- a/.claude/ralph/prd.json +++ b/.claude/ralph/prd.json @@ -17,7 +17,7 @@ "Typecheck passes" ], "priority": 1, - "passes": false, + "passes": true, "notes": "Foundation story. .claude/ already exists with settings.local.json — add to it, don't overwrite. CLAUDE.md doesn't exist yet." }, { @@ -34,7 +34,7 @@ "Typecheck passes" ], "priority": 2, - "passes": false, + "passes": true, "notes": "State file enables session continuity. ContinuousScanner already has _run_smart_loop() at src/scanner/automation/continuous.py:257." }, { @@ -51,7 +51,7 @@ "Typecheck passes" ], "priority": 3, - "passes": false, + "passes": true, "notes": "sync_closed_trades_rl() is at src/scanner/execution.py. Each journal entry has full context: gates, agents, regime, outcome." }, { @@ -68,7 +68,7 @@ "Typecheck passes" ], "priority": 4, - "passes": false, + "passes": true, "notes": "This is the evolution mechanism. Patterns that repeat become rules that actively gate or adjust trading behavior." }, { @@ -85,7 +85,7 @@ "Typecheck passes" ], "priority": 5, - "passes": false, + "passes": true, "notes": "This closes the loop: trade outcome → learning → rule → config change → better trades. Bounds prevent runaway tuning." }, { @@ -102,7 +102,7 @@ "Typecheck passes" ], "priority": 6, - "passes": false, + "passes": true, "notes": "Per-pair tuning is critical. EUR_USD 13.6 ATR behaves differently from USD_JPY 27.3 ATR. The scan phase calculates SL/TP in engine.py around line 1397." }, { @@ -119,7 +119,7 @@ "Typecheck passes" ], "priority": 7, - "passes": false, + "passes": true, "notes": "This is the meta-learning dashboard. Without metrics, we can't know if the learning system itself is improving." }, { @@ -135,7 +135,7 @@ "Typecheck passes" ], "priority": 8, - "passes": false, + "passes": true, "notes": "Observations build context even from non-trades. This data can later feed into a more sophisticated meta-learner." }, { @@ -152,7 +152,7 @@ "Typecheck passes" ], "priority": 9, - "passes": false, + "passes": true, "notes": "self_refine.py exists at project root. This wires LLM reasoning into the learning loop for qualitative insights." }, { @@ -168,7 +168,7 @@ "Typecheck passes" ], "priority": 10, - "passes": false, + "passes": true, "notes": "Context discipline. Files must stay concise or they become noise. Archive aggressively." }, { @@ -186,7 +186,7 @@ "Typecheck passes" ], "priority": 11, - "passes": false, + "passes": true, "notes": "buddy_scanner.py already has CLI with scan/watch/trade subcommands. Add 'learn' alongside." }, { @@ -206,7 +206,7 @@ "Typecheck passes" ], "priority": 12, - "passes": false, + "passes": true, "notes": "This is the integration story. All components from US-002 through US-010 get wired into the continuous loop here." } ] diff --git a/.claude/ralph/progress.txt b/.claude/ralph/progress.txt index 38ec4cb..2d22081 100644 --- a/.claude/ralph/progress.txt +++ b/.claude/ralph/progress.txt @@ -1,11 +1,47 @@ # Ralph Progress - Buddy Self-Improvement Loop -## Stories: 0/12 completed - -## Context -- ML Engine FX trading bot (Buddy) -- Currently: scan → trade → RL feedback loop works -- Missing: persistent learning, cross-session memory, adaptive config -- Key files: src/scanner/automation/continuous.py, src/scanner/execution.py, src/scanner/agents.py -- Trade journal: trained_data/trade_journal_rl.json (5 trades, 3 won / 2 lost) -- Account: OANDA practice, NAV ~$102,580 +## Stories: 12/12 completed + +## Completed +- US-001: Bootstrap .claude/ learning infrastructure ✓ (CLAUDE.md, learnings.md, rules/) +- US-002: Session state engine ✓ (src/scanner/automation/state_engine.py — StateEngine class) +- US-003: Trade outcome analyzer ✓ (src/scanner/automation/learning_engine.py — LearningEngine.analyze_trade) +- US-004: Rule promotion ✓ (LearningEngine.check_promotions — 3+ observations → promoted rule) +- US-005: Adaptive config tuner ✓ (src/scanner/automation/config_tuner.py — ConfigTuner class) +- US-006: Per-pair SL/TP adaptation ✓ (LearningEngine.update_pair_sl_tp + engine.py reads pair_sl_tp_config.json) +- US-007: Improvement tracker ✓ (src/scanner/automation/improvement_tracker.py — ImprovementTracker class) +- US-008: Observation logging ✓ (src/scanner/automation/observation_log.py — ObservationLog class) +- US-009: LLM deep analysis ✓ (LearningEngine.deep_analyze_loss + enable_llm_trade_analysis config flag) +- US-010: Consolidation guards ✓ (LearningEngine.audit/consolidate — archive promoted, cap files) +- US-011: Learn CLI command ✓ (cli/learn_commands.py + main.py dispatch — buddy learn [--analyze|--promote|--consolidate|--report|--status]) +- US-012: Full learning loop integration ✓ (continuous.py _run_learning_loop — wires all components into watch mode) + +## New Files Created +- src/scanner/automation/state_engine.py — StateEngine for cross-session state +- src/scanner/automation/learning_engine.py — LearningEngine for trade analysis + promotion + consolidation +- src/scanner/automation/config_tuner.py — ConfigTuner for adaptive config from rules +- src/scanner/automation/improvement_tracker.py — ImprovementTracker for meta-learning metrics +- src/scanner/automation/observation_log.py — ObservationLog for market pattern capture +- cli/learn_commands.py — CLI handler for `buddy learn` command + +## Modified Files +- src/scanner/automation/continuous.py — Added _run_learning_loop(), observation logging, config tuning +- src/scanner/engine.py — Per-pair SL/TP override from pair_sl_tp_config.json +- src/scanner/config.py — Added enable_llm_trade_analysis flag + smart profile enablement +- cli/argparser.py — Added 'learn' command + arguments +- main.py — Wired handle_learn into dispatch table + +## Architecture +``` +Scanner (engine.py) → Agents (agents.py) → Gates → Execution (execution.py) → OANDA + ↑ ↓ + ├── Config Tuner ← Rules ← Learnings ← RL Feedback ←── Trade Outcomes + ├── Observation Log (non-trade patterns) ↓ + ├── Per-pair SL/TP config Learning Engine (analyze_trade) + └── State Engine (session continuity) ↓ + Rule Promotion (3+ observations) + ↓ + Config Tuner (apply_to_config) + ↓ + Improvement Tracker (meta-metrics) +``` diff --git a/.claude/state.json b/.claude/state.json index 88581fc..9773dbb 100644 --- a/.claude/state.json +++ b/.claude/state.json @@ -15,15 +15,25 @@ "Minimum R:R ratio gate (1.2:1)", "Scan cycle JSONL logging", "Per-pair performance tracking", - "Bootstrap .claude/ learning infrastructure" + "Bootstrap .claude/ learning infrastructure", + "US-002: Session state engine (StateEngine class)", + "US-003: Trade outcome analyzer (LearningEngine.analyze_trade)", + "US-004: Rule promotion (3+ observations → promoted rules)", + "US-005: Adaptive config tuner (rules → config changes)", + "US-006: Per-pair adaptive SL/TP multipliers", + "US-007: Improvement tracker and meta-learning metrics", + "US-008: Observation logging for market patterns", + "US-009: LLM deep analysis for losing trades (>$100)", + "US-010: Learnings consolidation and anti-proliferation", + "US-011: buddy learn CLI command", + "US-012: Full learning loop integration into ContinuousScanner" ], - "next": "Run next scan cycle with improved position sizing and analyze results", + "next": "Run scan cycle in watch mode to validate full learning loop end-to-end", "open_questions": [ "EUR_USD model predicted LONG but pair was bearish — is the model stale or is this a regime issue?", - "Should we increase min_confidence threshold given EUR_USD losses at 67% confidence?", - "Walk-forward orchestrator (US-005 from old PRD) still deferred — needs train-joint run first" + "Should we increase min_confidence threshold given EUR_USD losses at 67% confidence?" ], - "last_updated": "2026-03-17T06:15:00Z", + "last_updated": "2026-03-18T00:00:00Z", "portfolio_snapshot": { "nav": 102580.84, "open_trades": 0, @@ -33,5 +43,6 @@ "session_losses": 2, "win_rate": 0.60 }, - "improvement_focus": "Self-improvement loop infrastructure (learnings → rules → config adaptation)" + "improvement_focus": "Full self-improvement loop live — monitoring learning extraction and rule promotion", + "scan_cycle_count": 0 } diff --git a/cli/argparser.py b/cli/argparser.py index ee296bd..e2ae787 100644 --- a/cli/argparser.py +++ b/cli/argparser.py @@ -41,6 +41,7 @@ "monitor", "find-candles", "transfer", + "learn", ] CLI_EPILOG = """ @@ -947,6 +948,41 @@ def _add_correlation_transfer_arguments(parser: argparse.ArgumentParser) -> None parser.add_argument("--transfer-epochs", type=int, default=50, help=argparse.SUPPRESS) +def _add_learn_arguments(parser: argparse.ArgumentParser) -> None: + """Add learning loop CLI arguments (buddy learn command).""" + learn_group = parser.add_argument_group("Learn", "Self-improvement learning loop commands") + learn_group.add_argument( + "--analyze", + action="store_true", + default=False, + help="For learn: run trade outcome analysis on all unanalyzed journal entries", + ) + learn_group.add_argument( + "--promote", + action="store_true", + default=False, + help="For learn: run promotion check and promote qualifying patterns to rules", + ) + learn_group.add_argument( + "--consolidate", + action="store_true", + default=False, + help="For learn: run learnings consolidation (archive promoted, group by category)", + ) + learn_group.add_argument( + "--report", + action="store_true", + default=False, + help="For learn: print improvement tracker report (total trades, win rate, trends)", + ) + learn_group.add_argument( + "--status", + action="store_true", + default=False, + help="For learn: print current state from .claude/state.json and recent learnings", + ) + + def create_argument_parser() -> argparse.ArgumentParser: """Create and configure the main argument parser. @@ -979,6 +1015,7 @@ def create_argument_parser() -> argparse.ArgumentParser: _add_wandb_arguments(parser) _add_multi_pair_arguments(parser) _add_correlation_transfer_arguments(parser) + _add_learn_arguments(parser) return parser diff --git a/cli/learn_commands.py b/cli/learn_commands.py new file mode 100644 index 0000000..1eaaa67 --- /dev/null +++ b/cli/learn_commands.py @@ -0,0 +1,153 @@ +"""CLI handler for the 'learn' command — manual learning loop triggers. + +US-011: Create buddy_learn CLI command for manual learning triggers. + +Usage: + buddy learn # Default: analyze + promote + report + buddy learn --analyze # Analyze unanalyzed trades + buddy learn --promote # Promote qualifying patterns to rules + buddy learn --consolidate # Consolidate learnings + buddy learn --report # Print improvement report + buddy learn --status # Print current state and learnings +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def handle_learn(args: Any) -> None: + """Dispatch the learn subcommand.""" + from rich.console import Console + console = Console() + + analyze = getattr(args, "analyze", False) + promote = getattr(args, "promote", False) + consolidate = getattr(args, "consolidate", False) + report = getattr(args, "report", False) + status = getattr(args, "status", False) + + # Default: analyze + promote + report + if not any([analyze, promote, consolidate, report, status]): + analyze = True + promote = True + report = True + + if status: + _show_status(console) + + if analyze: + _run_analyze(console) + + if promote: + _run_promote(console) + + if consolidate: + _run_consolidate(console) + + if report: + _run_report(console) + + +def _show_status(console: Any) -> None: + """Print current state and recent learnings.""" + from src.scanner.automation.state_engine import StateEngine + + console.print("\n[bold cyan]=== Current State ===[/bold cyan]") + se = StateEngine() + state = se.load_state() + + console.print(f"Goal: {state.get('goal', 'N/A')}") + console.print(f"Status: {state.get('status', 'N/A')}") + console.print(f"Last updated: {state.get('last_updated', 'N/A')}") + console.print(f"Improvement focus: {state.get('improvement_focus', 'N/A')}") + + snap = state.get("portfolio_snapshot", {}) + console.print(f"\n[bold]Portfolio:[/bold] NAV=${snap.get('nav', 0):,.2f}, " + f"Open={snap.get('open_trades', 0)}, " + f"P/L=${snap.get('total_realized_pnl', 0):,.2f}, " + f"Win rate={snap.get('win_rate', 0):.0%}") + + # Recent learnings + learnings_path = Path(".claude/learnings.md") + if learnings_path.exists(): + lines = learnings_path.read_text().strip().split("\n") + recent = [l for l in lines[-10:] if l.strip().startswith("-")] + if recent: + console.print("\n[bold]Recent Learnings:[/bold]") + for line in recent: + console.print(f" {line}") + console.print() + + +def _run_analyze(console: Any) -> None: + """Run trade outcome analysis on unanalyzed journal entries.""" + from src.scanner.automation.learning_engine import LearningEngine + + console.print("[cyan]Analyzing trade outcomes...[/cyan]") + le = LearningEngine() + + journal_path = Path("trained_data/trade_journal_rl.json") + if not journal_path.exists(): + console.print("[yellow]No trade journal found.[/yellow]") + return + + entries = json.loads(journal_path.read_text()) + closed = [e for e in entries if e.get("outcome") is not None] + + total_learnings = 0 + for entry in closed: + learnings = le.analyze_trade(entry) + if learnings: + count = le.append_to_learnings(learnings) + total_learnings += count + + console.print(f"[green]Extracted {total_learnings} learning entries from {len(closed)} closed trades.[/green]") + + +def _run_promote(console: Any) -> None: + """Check for and promote qualifying patterns.""" + from src.scanner.automation.learning_engine import LearningEngine + + console.print("[cyan]Checking for rule promotions...[/cyan]") + le = LearningEngine() + promoted = le.check_promotions() + + if promoted: + console.print(f"[green]Promoted {len(promoted)} patterns to rules:[/green]") + for rule in promoted: + console.print(f" {rule}") + else: + console.print("[dim]No patterns qualify for promotion yet (need 3+ observations).[/dim]") + + +def _run_consolidate(console: Any) -> None: + """Run learnings consolidation.""" + from src.scanner.automation.learning_engine import LearningEngine + + console.print("[cyan]Consolidating learnings...[/cyan]") + le = LearningEngine() + audit_result = le.audit() + + actions = audit_result.get("actions", []) + if actions: + for action in actions: + console.print(f" [green]{action}[/green]") + else: + console.print("[dim]No consolidation needed.[/dim]") + + +def _run_report(console: Any) -> None: + """Print improvement tracker report.""" + from src.scanner.automation.improvement_tracker import ImprovementTracker + + console.print("\n[bold cyan]=== Improvement Report ===[/bold cyan]") + tracker = ImprovementTracker() + report = tracker.generate_report() + console.print(report) + console.print() diff --git a/main.py b/main.py index 598ac2e..f0f8c65 100644 --- a/main.py +++ b/main.py @@ -89,6 +89,7 @@ retrain_all, ) from cli.candle_optimizer import find_optimal_candles +from cli.learn_commands import handle_learn # --------------------------------------------------------------------------- # Global state @@ -562,6 +563,7 @@ def _handle_transfer(args: Any) -> None: "model-status": _handle_model_status, "find-candles": _handle_find_candles, "transfer": _handle_transfer, + "learn": handle_learn, } diff --git a/src/scanner/automation/config_tuner.py b/src/scanner/automation/config_tuner.py new file mode 100644 index 0000000..8979eba --- /dev/null +++ b/src/scanner/automation/config_tuner.py @@ -0,0 +1,157 @@ +"""Adaptive config tuner that reads promoted rules and adjusts scanner config. + +Closes the learning loop: trade outcome -> learning -> rule -> config change -> better trades. + +US-005: Create adaptive config tuner that reads rules and adjusts scanner config. +""" + +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from src.scanner.config import ScannerConfig + +logger = logging.getLogger(__name__) + +RULES_PATH = Path(".claude/rules/trading.md") +ADJUSTMENTS_PATH = Path(".claude/config_adjustments.json") + +# Bounds to prevent runaway tuning +BOUNDS = { + "atr_sl_multiplier": (0.5, 2.0), + "atr_tp_multiplier": (0.8, 3.0), + "max_uncertainty_score": (0.30, 0.95), + "weighted_vote_threshold": (0.45, 0.95), + "max_model_disagreement": (0.20, 0.95), +} + + +class ConfigTuner: + """Reads promoted rules and applies config adjustments.""" + + def __init__( + self, + rules_path: Optional[Path] = None, + adjustments_path: Optional[Path] = None, + ): + self.rules_path = rules_path or RULES_PATH + self.adjustments_path = adjustments_path or ADJUSTMENTS_PATH + self._applied_rules: set[str] = set() + + def load_rules(self) -> List[str]: + """Parse .claude/rules/trading.md and extract promoted rule lines.""" + if not self.rules_path.exists(): + return [] + + content = self.rules_path.read_text() + rules: List[str] = [] + in_promoted = False + + for line in content.split("\n"): + stripped = line.strip() + if "## Promoted Rules" in stripped: + in_promoted = True + continue + if in_promoted and stripped.startswith("- ["): + rules.append(stripped) + elif in_promoted and stripped.startswith("##"): + break # Next section + + return rules + + def apply_to_config(self, config: "ScannerConfig") -> List[Dict[str, Any]]: + """Read promoted rules and adjust config fields. Returns list of adjustments made.""" + rules = self.load_rules() + adjustments: List[Dict[str, Any]] = [] + + for rule in rules: + # Skip already applied rules + rule_hash = hash(rule) + if rule_hash in self._applied_rules: + continue + + adj = self._apply_rule(rule, config) + if adj: + adjustments.append(adj) + self._applied_rules.add(rule_hash) + + if adjustments: + self._log_adjustments(adjustments) + + return adjustments + + def _apply_rule(self, rule: str, config: "ScannerConfig") -> Optional[Dict[str, Any]]: + """Parse a single rule and apply to config. Returns adjustment dict or None.""" + rule_lower = rule.lower() + + # Pattern: "Increase atr_sl_multiplier by 0.1 for {pair}" + sl_match = re.search(r"increase atr_sl_multiplier.*?for (\w+)", rule_lower) + if sl_match: + # Global adjustment (per-pair is handled by pair_sl_tp_config.json) + old = config.atr_sl_multiplier + new = min(BOUNDS["atr_sl_multiplier"][1], old + 0.1) + if new != old: + config.atr_sl_multiplier = new + return {"field": "atr_sl_multiplier", "old": old, "new": new, "rule": rule} + + # Pattern: "Increase atr_tp_multiplier by 0.1 for {pair}" + tp_match = re.search(r"increase atr_tp_multiplier.*?for (\w+)", rule_lower) + if tp_match: + old = config.atr_tp_multiplier + new = min(BOUNDS["atr_tp_multiplier"][1], old + 0.1) + if new != old: + config.atr_tp_multiplier = new + return {"field": "atr_tp_multiplier", "old": old, "new": new, "rule": rule} + + # Pattern: "Lower max_uncertainty_score by 0.02" + if "lower max_uncertainty_score" in rule_lower: + old = config.max_uncertainty_score + new = max(BOUNDS["max_uncertainty_score"][0], old - 0.02) + if new != old: + config.max_uncertainty_score = new + return {"field": "max_uncertainty_score", "old": old, "new": new, "rule": rule} + + # Pattern: "Prefer weighted_vote_score > 0.7" / "Lower weighted_vote_threshold" + if "weighted_vote" in rule_lower and "prefer" in rule_lower: + old = config.weighted_vote_threshold + new = max(BOUNDS["weighted_vote_threshold"][0], old - 0.01) + if new != old: + config.weighted_vote_threshold = new + return {"field": "weighted_vote_threshold", "old": old, "new": new, "rule": rule} + + # Pattern: "Lower max_model_disagreement by 0.02" + if "lower max_model_disagreement" in rule_lower: + old = config.max_model_disagreement + new = max(BOUNDS["max_model_disagreement"][0], old - 0.02) + if new != old: + config.max_model_disagreement = new + return {"field": "max_model_disagreement", "old": old, "new": new, "rule": rule} + + return None + + def _log_adjustments(self, adjustments: List[Dict[str, Any]]) -> None: + """Persist adjustments to .claude/config_adjustments.json.""" + existing: List[Dict[str, Any]] = [] + if self.adjustments_path.exists(): + try: + existing = json.loads(self.adjustments_path.read_text()) + except Exception: + pass + + now = datetime.now(timezone.utc).isoformat() + for adj in adjustments: + adj["timestamp"] = now + existing.append(adj) + logger.info( + "Config adjustment: %s %.4f -> %.4f (rule: %s)", + adj["field"], adj["old"], adj["new"], adj.get("rule", "")[:60], + ) + + self.adjustments_path.parent.mkdir(parents=True, exist_ok=True) + self.adjustments_path.write_text(json.dumps(existing, indent=2)) diff --git a/src/scanner/automation/continuous.py b/src/scanner/automation/continuous.py index eaa19c8..2d16d99 100644 --- a/src/scanner/automation/continuous.py +++ b/src/scanner/automation/continuous.py @@ -155,7 +155,29 @@ def run( # Log scan cycle for analytics self._log_scan_cycle(result, auto_execute) - # Smart trading loop: monitor, drawdown guardian, RL sync + # Log observations from scan results (US-008) + try: + from src.scanner.automation.observation_log import ObservationLog + obs_log = ObservationLog() + obs_count = 0 + for analysis in result.analyses: + obs_count += obs_log.log_from_analysis(analysis) + if obs_count > 0 and console: + console.print(f" [dim]Observations: {obs_count} patterns logged[/dim]") + except Exception as obs_err: + logger.debug(f"Observation logging error: {obs_err}") + + # Apply config tuning before next scan (US-005) + try: + from src.scanner.automation.config_tuner import ConfigTuner + ct = ConfigTuner() + adjustments = ct.apply_to_config(self.scanner.config) + if adjustments and console: + console.print(f" [dim]Config tuned: {len(adjustments)} adjustments[/dim]") + except Exception as tune_err: + logger.debug(f"Pre-scan config tuning error: {tune_err}") + + # Smart trading loop: monitor, drawdown guardian, RL sync, learning self._run_smart_loop() # Idle maintenance @@ -262,7 +284,7 @@ def _filter_correlated_exposure(self, tradeable: list) -> list: return tradeable def _run_smart_loop(self) -> None: - """Run the smart trading loop: monitor open trades, drawdown guardian, RL sync.""" + """Run the smart trading loop: monitor, guardian, RL sync, learning, adaptation.""" try: from src.scanner.execution import ExecutionManager @@ -289,7 +311,8 @@ def _run_smart_loop(self) -> None: # 3. RL feedback sync rl_result = em.sync_closed_trades_rl() - if rl_result.get("trades_synced", 0) > 0 and console: + trades_synced = rl_result.get("trades_synced", 0) + if trades_synced > 0 and console: console.print( f" [cyan]RL sync: {rl_result['detail']}[/cyan]" ) @@ -307,9 +330,112 @@ def _run_smart_loop(self) -> None: except Exception as decay_err: logger.debug(f"Weight decay error: {decay_err}") + # 5. Learning loop (US-012) + self._run_learning_loop(em, trades_synced) + except Exception as e: logger.debug(f"Smart loop error: {e}") + def _run_learning_loop(self, em: object, trades_synced: int) -> None: + """Step 5: Learning engine analysis, promotion, config tuning, metrics. + + Wraps all learning components in try/except to never crash the scan loop. + """ + try: + import json + from pathlib import Path + from src.scanner.automation.learning_engine import LearningEngine + from src.scanner.automation.config_tuner import ConfigTuner + from src.scanner.automation.improvement_tracker import ImprovementTracker + from src.scanner.automation.state_engine import StateEngine + + le = LearningEngine() + learnings_added = 0 + rules_promoted = 0 + config_adjustments = [] + + # 5a. Analyze each newly synced trade + if trades_synced > 0: + journal_path = Path("trained_data/trade_journal_rl.json") + if journal_path.exists(): + entries = json.loads(journal_path.read_text()) + closed = [e for e in entries if e.get("outcome") is not None] + + for entry in closed: + insights = le.analyze_trade(entry) + if insights: + learnings_added += le.append_to_learnings(insights) + + # Per-pair SL/TP adaptation (US-006) + le.update_pair_sl_tp(entry) + + # LLM deep analysis for significant losses (US-009) + if getattr(self.scanner.config, "enable_llm_trade_analysis", False): + outcome = entry.get("outcome", {}) + if outcome.get("realized_pl", 0) < -100: + pair_entries = [e for e in entries if e.get("pair") == entry.get("pair")] + llm_insights = le.deep_analyze_loss(entry, pair_entries) + if llm_insights: + learnings_added += le.append_to_learnings(llm_insights) + + # 5b. Check for rule promotions + promoted = le.check_promotions() + rules_promoted = len(promoted) + + # 5c. Apply config tuning from promoted rules + try: + ct = ConfigTuner() + config_adjustments = ct.apply_to_config(self.scanner.config) + except Exception as tune_err: + logger.debug(f"Config tuning error: {tune_err}") + + # 5d. Record session metrics + if trades_synced > 0: + try: + tracker = ImprovementTracker() + journal_path = Path("trained_data/trade_journal_rl.json") + all_entries = json.loads(journal_path.read_text()) if journal_path.exists() else [] + tracker.record_session( + trades=all_entries, + learnings_added=learnings_added, + rules_promoted=rules_promoted, + config_adjustments=config_adjustments, + ) + except Exception as track_err: + logger.debug(f"Improvement tracking error: {track_err}") + + # 5e. Update portfolio snapshot + try: + se = StateEngine() + se.update_portfolio_snapshot() + except Exception as state_err: + logger.debug(f"State update error: {state_err}") + + # 5f. Audit every 10th cycle + try: + se = StateEngine() + cycle = se.increment_scan_cycle() + if cycle % 10 == 0: + audit_result = le.audit() + if audit_result.get("actions") and console: + console.print(f" [dim]Audit: {audit_result['actions']}[/dim]") + except Exception as audit_err: + logger.debug(f"Audit error: {audit_err}") + + # Log learning activity + if (learnings_added > 0 or rules_promoted > 0) and console: + console.print( + f" [magenta]Learning: {learnings_added} insights captured, " + f"{rules_promoted} rules promoted[/magenta]" + ) + if config_adjustments and console: + console.print( + f" [magenta]Config: {len(config_adjustments)} adjustments applied[/magenta]" + ) + + except Exception as e: + logger.debug(f"Learning loop error: {e}") + def _sleep_with_progress(self, minutes: int): """Sleep with progress indication, allowing Ctrl+C to interrupt.""" if console: diff --git a/src/scanner/automation/improvement_tracker.py b/src/scanner/automation/improvement_tracker.py new file mode 100644 index 0000000..9bf16c7 --- /dev/null +++ b/src/scanner/automation/improvement_tracker.py @@ -0,0 +1,148 @@ +"""Improvement tracker — measures whether the learning system itself is improving. + +US-007: Add session improvement summary and metrics tracking. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +IMPROVEMENT_LOG_PATH = Path("trained_data/improvement_log.jsonl") + + +class ImprovementTracker: + """Records and analyzes session-level improvement metrics.""" + + def __init__(self, log_path: Optional[Path] = None): + self.log_path = log_path or IMPROVEMENT_LOG_PATH + + def record_session( + self, + trades: List[Dict[str, Any]], + learnings_added: int = 0, + rules_promoted: int = 0, + config_adjustments: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """Append a session record to improvement_log.jsonl.""" + closed = [t for t in trades if t.get("outcome")] + wins = sum(1 for t in closed if t["outcome"].get("trade_won", False)) + losses = len(closed) - wins + net_pnl = sum(t["outcome"].get("realized_pl", 0) for t in closed) + avg_pips = 0.0 + if closed: + avg_pips = sum(abs(t["outcome"].get("pnl_pips", 0)) for t in closed) / len(closed) + + # Snapshot agent weights + weights_snapshot: Dict[str, float] = {} + try: + wp = Path("trained_data/models/agent_weights.json") + if wp.exists(): + weights_snapshot = json.loads(wp.read_text()) + except Exception: + pass + + record = { + "date": datetime.now(timezone.utc).isoformat(), + "session_trades": len(closed), + "wins": wins, + "losses": losses, + "net_pnl": round(net_pnl, 2), + "win_rate": round(wins / len(closed), 3) if closed else 0.0, + "avg_pips": round(avg_pips, 1), + "learnings_added": learnings_added, + "rules_promoted": rules_promoted, + "config_adjustments": config_adjustments or [], + "agent_weights_snapshot": weights_snapshot, + } + + self.log_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.log_path, "a") as f: + f.write(json.dumps(record, default=str) + "\n") + + logger.info("Session recorded: %d trades, %.0f%% win, $%.2f P/L", len(closed), record["win_rate"] * 100, net_pnl) + return record + + def get_trend(self, window: int = 10) -> Dict[str, str]: + """Return rolling metrics over last N sessions.""" + records = self._load_records() + if len(records) < 2: + return {"win_rate_trend": "insufficient_data", "avg_pnl_trend": "insufficient_data", "learning_velocity": "0"} + + recent = records[-window:] + older = records[:-window] if len(records) > window else records[:1] + + recent_wr = _avg([r.get("win_rate", 0) for r in recent]) + older_wr = _avg([r.get("win_rate", 0) for r in older]) + recent_pnl = _avg([r.get("net_pnl", 0) for r in recent]) + older_pnl = _avg([r.get("net_pnl", 0) for r in older]) + learning_vel = _avg([r.get("learnings_added", 0) for r in recent]) + + def _trend(recent_val: float, older_val: float) -> str: + diff = recent_val - older_val + if abs(diff) < 0.02: + return "stable" + return "improving" if diff > 0 else "declining" + + return { + "win_rate_trend": _trend(recent_wr, older_wr), + "avg_pnl_trend": _trend(recent_pnl, older_pnl), + "learning_velocity": f"{learning_vel:.1f}", + } + + def generate_report(self) -> str: + """Return formatted improvement report string.""" + records = self._load_records() + if not records: + return "No improvement data recorded yet." + + total_trades = sum(r.get("session_trades", 0) for r in records) + total_wins = sum(r.get("wins", 0) for r in records) + total_pnl = sum(r.get("net_pnl", 0) for r in records) + overall_wr = total_wins / total_trades if total_trades else 0 + + # Best/worst pair (from recent records) + pair_pnl: Dict[str, float] = {} + for r in records: + for adj in r.get("config_adjustments", []): + pass # adjustments don't have pair info directly + + trend = self.get_trend() + total_learnings = sum(r.get("learnings_added", 0) for r in records) + total_promotions = sum(r.get("rules_promoted", 0) for r in records) + + lines = [ + "=== Improvement Report ===", + f"Sessions: {len(records)}", + f"Total trades: {total_trades} ({total_wins}W / {total_trades - total_wins}L)", + f"Overall win rate: {overall_wr:.0%}", + f"Total P/L: ${total_pnl:.2f}", + f"Total learnings extracted: {total_learnings}", + f"Total rules promoted: {total_promotions}", + f"Win rate trend: {trend['win_rate_trend']}", + f"P/L trend: {trend['avg_pnl_trend']}", + f"Learning velocity: {trend['learning_velocity']}/session", + ] + return "\n".join(lines) + + def _load_records(self) -> List[Dict[str, Any]]: + """Load all records from JSONL file.""" + if not self.log_path.exists(): + return [] + records = [] + for line in self.log_path.read_text().strip().split("\n"): + if line.strip(): + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + return records + + +def _avg(values: List[float]) -> float: + return sum(values) / len(values) if values else 0.0 diff --git a/src/scanner/automation/learning_engine.py b/src/scanner/automation/learning_engine.py new file mode 100644 index 0000000..1fc5802 --- /dev/null +++ b/src/scanner/automation/learning_engine.py @@ -0,0 +1,550 @@ +"""Trade outcome analyzer and learning extraction engine. + +Automatically analyzes closed trades, extracts actionable patterns into +.claude/learnings.md, and promotes repeated patterns into trading rules. + +US-003: Build trade outcome analyzer for automatic learning extraction. +US-004: Implement rule promotion from learnings (pattern -> rule). +US-010: Learnings consolidation and anti-proliferation guard. +""" + +from __future__ import annotations + +import json +import logging +import re +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +LEARNINGS_PATH = Path(".claude/learnings.md") +LEARNINGS_ARCHIVE_PATH = Path(".claude/learnings-archive.md") +RULES_TRADING_PATH = Path(".claude/rules/trading.md") + +CATEGORIES = [ + "sizing", + "entry_timing", + "sl_tp", + "agent_accuracy", + "regime", + "pair_behavior", +] + + +@dataclass +class LearningEntry: + date: str + category: str + insight: str + action: str + source_trade_id: str = "" + + +class LearningEngine: + """Analyzes closed trades and manages the learning -> rule promotion pipeline.""" + + def __init__( + self, + learnings_path: Optional[Path] = None, + rules_path: Optional[Path] = None, + ): + self.learnings_path = learnings_path or LEARNINGS_PATH + self.rules_path = rules_path or RULES_TRADING_PATH + + # ------------------------------------------------------------------ + # US-003: Trade outcome analysis + # ------------------------------------------------------------------ + + def analyze_trade(self, entry: Dict[str, Any]) -> List[LearningEntry]: + """Examine a closed trade and return actionable learning entries. + + Analysis rules: + 1. SL hit and pnl_pips > 2x atr_pips -> 'sl_too_tight for {pair}' + 2. TP hit in <15min -> 'tp_could_be_wider for {pair}' + 3. Lost and uncertainty_score > 0.45 -> 'uncertainty_was_warning for {pair}' + 4. Won and weighted_vote_score > 0.7 -> 'high_consensus_works' + 5. model_disagreement > 0.3 and lost -> 'disagreement_predicted_loss' + """ + entries: List[LearningEntry] = [] + outcome = entry.get("outcome") + if not outcome: + return entries + + now = datetime.now(timezone.utc).strftime("%Y-%m-%d") + pair = entry.get("pair", "UNKNOWN") + trade_id = str(entry.get("trade_id", "")) + trade_won = outcome.get("trade_won", False) + pnl_pips = abs(outcome.get("pnl_pips", 0)) + realized_pl = outcome.get("realized_pl", 0) + + # Extract context from the entry + sl_pips = entry.get("sl_pips", 0) or 0 + tp_pips = entry.get("tp_pips", 0) or 0 + atr_pips = entry.get("atr_pips", 0) or 0 + confidence = entry.get("confidence", 0) + agents = entry.get("agents", {}) + agent_reasons = agents.get("agent_reasons", []) + + # Extract agent metrics + uncertainty_score = 0.0 + weighted_vote_score = 0.0 + model_disagreement = 0.0 + for ar in agent_reasons: + name = ar.get("name", "") + if name == "uncertainty": + uncertainty_score = ar.get("score", 0) + meta = ar.get("metadata", {}) + if "weighted_vote_score" in meta: + weighted_vote_score = meta["weighted_vote_score"] + if "model_disagreement" in meta: + model_disagreement = meta["model_disagreement"] + + # Use top-level fields if available + if not weighted_vote_score: + weighted_vote_score = entry.get("weighted_vote_score", 0) + if not model_disagreement: + model_disagreement = entry.get("model_disagreement", 0) + + # Rule 1: SL too tight + if not trade_won and atr_pips > 0 and pnl_pips > 2 * atr_pips: + entries.append(LearningEntry( + date=now, + category="sl_tp", + insight=f"sl_too_tight for {pair}: lost {pnl_pips:.1f}p > 2x ATR ({atr_pips:.1f}p)", + action=f"Increase atr_sl_multiplier for {pair}", + source_trade_id=trade_id, + )) + + # Rule 2: TP hit too fast (< 15 min) + if trade_won: + open_time = entry.get("timestamp") or entry.get("open_time", "") + close_time = outcome.get("close_time", "") + if open_time and close_time: + try: + t_open = datetime.fromisoformat(str(open_time).replace("Z", "+00:00")) + t_close = datetime.fromisoformat(str(close_time).replace("Z", "+00:00")) + duration_min = (t_close - t_open).total_seconds() / 60 + if duration_min < 15: + entries.append(LearningEntry( + date=now, + category="sl_tp", + insight=f"tp_could_be_wider for {pair}: TP hit in {duration_min:.0f}min", + action=f"Increase atr_tp_multiplier for {pair}", + source_trade_id=trade_id, + )) + except (ValueError, TypeError): + pass + + # Rule 3: Uncertainty was warning + if not trade_won and uncertainty_score > 0.45: + entries.append(LearningEntry( + date=now, + category="agent_accuracy", + insight=f"uncertainty_was_warning for {pair}: score={uncertainty_score:.2f}, lost ${abs(realized_pl):.2f}", + action="Lower max_uncertainty_score threshold", + source_trade_id=trade_id, + )) + + # Rule 4: High consensus works + if trade_won and weighted_vote_score > 0.7: + entries.append(LearningEntry( + date=now, + category="agent_accuracy", + insight=f"high_consensus_works: vote={weighted_vote_score:.2f}, won ${realized_pl:.2f}", + action="Prefer high-consensus setups (>0.7 weighted vote)", + source_trade_id=trade_id, + )) + + # Rule 5: Disagreement predicted loss + if not trade_won and model_disagreement > 0.3: + entries.append(LearningEntry( + date=now, + category="agent_accuracy", + insight=f"disagreement_predicted_loss: disagreement={model_disagreement:.2f}, lost ${abs(realized_pl):.2f}", + action="Lower max_model_disagreement threshold", + source_trade_id=trade_id, + )) + + # Pair behavior: track direction accuracy + direction = entry.get("direction", "") + entries.append(LearningEntry( + date=now, + category="pair_behavior", + insight=f"{pair} {direction} {'won' if trade_won else 'lost'}: {pnl_pips:.1f}p (conf={confidence:.0%})", + action=f"Track {pair} directional accuracy", + source_trade_id=trade_id, + )) + + return entries + + def append_to_learnings(self, entries: List[LearningEntry]) -> int: + """Append formatted entries to .claude/learnings.md with date prefix.""" + if not entries: + return 0 + + self.learnings_path.parent.mkdir(parents=True, exist_ok=True) + + lines: List[str] = [] + for e in entries: + lines.append(f"- **[{e.date}]** `{e.category}` | {e.insight} → *{e.action}*") + + existing = "" + if self.learnings_path.exists(): + existing = self.learnings_path.read_text() + + # Append under a new section if date changed + date_header = f"\n### Auto-extracted {entries[0].date}\n" + if date_header.strip() not in existing: + existing += date_header + + existing += "\n".join(lines) + "\n" + self.learnings_path.write_text(existing) + logger.info("Appended %d learning entries to %s", len(entries), self.learnings_path) + return len(entries) + + # ------------------------------------------------------------------ + # US-004: Rule promotion + # ------------------------------------------------------------------ + + def check_promotions(self) -> List[str]: + """Read learnings.md, count patterns, promote those with 3+ occurrences.""" + if not self.learnings_path.exists(): + return [] + + content = self.learnings_path.read_text() + lines = content.split("\n") + + # Extract pattern keys from learning entries + # Pattern: `category` | pattern_key ... + pattern_counter: Counter[str] = Counter() + pattern_lines: Dict[str, List[int]] = {} + + for idx, line in enumerate(lines): + match = re.search(r"`(\w+)`\s*\|\s*(\S+)", line) + if match and "[PROMOTED]" not in line: + category = match.group(1) + pattern_key = match.group(2) + full_key = f"{category}:{pattern_key}" + pattern_counter[full_key] += 1 + pattern_lines.setdefault(full_key, []).append(idx) + + promoted: List[str] = [] + now = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + for key, count in pattern_counter.items(): + if count < 3: + continue + + category, pattern = key.split(":", 1) + + # Build promotion rule + if "sl_too_tight" in pattern: + pair = pattern.replace("sl_too_tight for ", "").strip() + rule = f"Increase atr_sl_multiplier by 0.1 for {pair} (SL too tight observed {count} times)" + elif "tp_could_be_wider" in pattern: + pair = pattern.replace("tp_could_be_wider for ", "").strip() + rule = f"Increase atr_tp_multiplier by 0.1 for {pair} (TP hit too fast {count} times)" + elif "uncertainty_was_warning" in pattern: + rule = f"Lower max_uncertainty_score by 0.02 (uncertainty predicted {count} losses)" + elif "high_consensus_works" in pattern: + rule = f"Prefer weighted_vote_score > 0.7 (high consensus won {count} times)" + elif "disagreement_predicted_loss" in pattern: + rule = f"Lower max_model_disagreement by 0.02 (disagreement predicted {count} losses)" + else: + rule = f"{pattern} observed {count} times — review and adapt" + + promotion_line = f"- [{now}] {category}: {rule} (promoted from {count} observations)" + + # Append to rules/trading.md + self._append_rule(promotion_line) + promoted.append(promotion_line) + + # Mark source learnings as [PROMOTED] + for line_idx in pattern_lines.get(key, []): + if line_idx < len(lines): + lines[line_idx] = lines[line_idx] + " [PROMOTED]" + + if promoted: + self.learnings_path.write_text("\n".join(lines)) + logger.info("Promoted %d patterns to rules", len(promoted)) + + return promoted + + def _append_rule(self, rule_line: str) -> None: + """Append a promoted rule to .claude/rules/trading.md.""" + self.rules_path.parent.mkdir(parents=True, exist_ok=True) + + existing = "" + if self.rules_path.exists(): + existing = self.rules_path.read_text() + + # Don't duplicate + if rule_line.strip() in existing: + return + + # Add under a Promoted Rules section + promoted_header = "\n## Promoted Rules\n" + if promoted_header.strip() not in existing: + existing += promoted_header + + existing += rule_line + "\n" + self.rules_path.write_text(existing) + + # ------------------------------------------------------------------ + # US-009: LLM deep analysis for significant losses + # ------------------------------------------------------------------ + + def deep_analyze_loss( + self, entry: Dict[str, Any], pair_history: Optional[List[Dict[str, Any]]] = None + ) -> List[LearningEntry]: + """Use local 3B model to analyze a losing trade and suggest improvements. + + Only call for trades losing > $100. Uses llm_providers.llm_call() which + prefers the local Buddy Planner 3B (Qwen2.5-3B-Instruct) — no API tokens needed. + """ + outcome = entry.get("outcome", {}) + realized_pl = outcome.get("realized_pl", 0) + if realized_pl >= 0 or abs(realized_pl) < 100: + return [] + + pair = entry.get("pair", "UNKNOWN") + direction = entry.get("direction", "UNKNOWN") + confidence = entry.get("confidence", 0) + agents = entry.get("agents", {}) + regime = entry.get("volatility_regime", "UNKNOWN") + + system_prompt = ( + "You are Buddy Planner 3B, an FX trading analysis assistant. " + "Analyze losing trades and suggest specific, actionable config improvements. " + "Be concise. Return 1-3 insights in this exact format per line:\n" + "CATEGORY: insight text -> action to take\n" + "Categories: sizing, entry_timing, sl_tp, agent_accuracy, regime, pair_behavior" + ) + + prompt = ( + f"Analyze this losing FX trade:\n" + f"Pair: {pair}, Direction: {direction}, Confidence: {confidence:.0%}\n" + f"Entry: {entry.get('entry_price', 0)}, SL: {entry.get('sl_pips', 0)}p, TP: {entry.get('tp_pips', 0)}p\n" + f"Outcome: ${realized_pl:.2f}, {outcome.get('pnl_pips', 0):.1f} pips\n" + f"Regime: {regime}\n" + f"Agent verdicts: {json.dumps(agents.get('agent_reasons', [])[:3], default=str)}\n" + ) + + if pair_history: + recent = pair_history[-5:] + history_str = ", ".join( + f"{'W' if h.get('outcome', {}).get('trade_won') else 'L'} {h.get('outcome', {}).get('pnl_pips', 0):.0f}p" + for h in recent if h.get("outcome") + ) + prompt += f"Recent {pair} history: {history_str}\n" + + prompt += "\nWhat caused this loss and what config change would prevent it?" + + try: + from llm_providers import llm_call, select_buddy_provider_name + provider = select_buddy_provider_name() # Prefers local 3B model + response = llm_call( + prompt, + system_prompt=system_prompt, + provider=provider, + max_tokens=256, + temperature=0.1, + ) + if not response: + return [] + except Exception as e: + logger.warning(f"Local model deep analysis failed: {e}") + return [] + + # Parse LLM response into LearningEntry objects + entries: List[LearningEntry] = [] + now = datetime.now(timezone.utc).strftime("%Y-%m-%d") + for line in response.split("\n"): + line = line.strip() + if not line or ":" not in line: + continue + parts = line.split(":", 1) + category = parts[0].strip().lower().replace(" ", "_") + if category not in CATEGORIES: + category = "pair_behavior" + rest = parts[1].strip() + action = "" + if "->" in rest: + insight_part, action = rest.split("->", 1) + action = action.strip() + else: + insight_part = rest + action = "Review and adapt" + entries.append(LearningEntry( + date=now, + category=category, + insight=f"[3B] {insight_part.strip()}", + action=action, + source_trade_id=str(entry.get("trade_id", "")), + )) + + return entries[:3] # Cap at 3 insights + + # ------------------------------------------------------------------ + # US-010: Consolidation and anti-proliferation + # ------------------------------------------------------------------ + + def audit(self) -> Dict[str, Any]: + """Check file sizes and trigger consolidation if needed. + + Returns dict with audit results. + """ + results: Dict[str, Any] = {"actions": []} + + # Check learnings.md line count + if self.learnings_path.exists(): + learnings_lines = self.learnings_path.read_text().split("\n") + if len(learnings_lines) > 30: + self.consolidate() + results["actions"].append(f"Consolidated learnings ({len(learnings_lines)} lines)") + + # Check rules/trading.md line count + if self.rules_path.exists(): + rules_lines = self.rules_path.read_text().split("\n") + if len(rules_lines) > 50: + results["actions"].append(f"Rules file large ({len(rules_lines)} lines) — consider splitting") + + # Check config_adjustments.json + adj_path = Path(".claude/config_adjustments.json") + if adj_path.exists(): + try: + adjustments = json.loads(adj_path.read_text()) + if len(adjustments) > 100: + # Archive entries older than 30 days + cutoff = datetime.now(timezone.utc).timestamp() - (30 * 86400) + recent = [a for a in adjustments if _parse_ts(a.get("timestamp", "")) > cutoff] + archived = len(adjustments) - len(recent) + adj_path.write_text(json.dumps(recent, indent=2)) + results["actions"].append(f"Archived {archived} old config adjustments") + except Exception as e: + logger.debug(f"Config adjustments audit error: {e}") + + if results["actions"]: + logger.info("Audit results: %s", results["actions"]) + return results + + def consolidate(self) -> int: + """Group learnings by category, archive promoted/old, keep active.""" + if not self.learnings_path.exists(): + return 0 + + content = self.learnings_path.read_text() + lines = content.split("\n") + + active: List[str] = [] + archived: List[str] = [] + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + active.append(line) + continue + if "[PROMOTED]" in line: + archived.append(line) + else: + active.append(line) + + # Archive promoted entries + if archived: + LEARNINGS_ARCHIVE_PATH.parent.mkdir(parents=True, exist_ok=True) + archive_content = "" + if LEARNINGS_ARCHIVE_PATH.exists(): + archive_content = LEARNINGS_ARCHIVE_PATH.read_text() + now = datetime.now(timezone.utc).strftime("%Y-%m-%d") + archive_content += f"\n### Archived {now}\n" + archive_content += "\n".join(archived) + "\n" + LEARNINGS_ARCHIVE_PATH.write_text(archive_content) + + self.learnings_path.write_text("\n".join(active)) + logger.info("Consolidated: archived %d promoted entries", len(archived)) + + return len(archived) + + # ------------------------------------------------------------------ + # US-006: Per-pair adaptive SL/TP + # ------------------------------------------------------------------ + + def update_pair_sl_tp(self, entry: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update per-pair SL/TP multipliers based on trade outcome. + + Returns updated config for the pair, or None if no change. + """ + outcome = entry.get("outcome") + if not outcome: + return None + + pair = entry.get("pair", "") + if not pair: + return None + + config_path = Path("trained_data/models/pair_sl_tp_config.json") + config: Dict[str, Any] = {} + if config_path.exists(): + try: + config = json.loads(config_path.read_text()) + except Exception: + pass + + pair_cfg = config.get(pair, { + "atr_sl_multiplier": 1.0, + "atr_tp_multiplier": 1.5, + "last_updated": "", + "sample_size": 0, + }) + + trade_won = outcome.get("trade_won", False) + pnl_pips = abs(outcome.get("pnl_pips", 0)) + atr_pips = entry.get("atr_pips", 0) or 0 + + changed = False + + if trade_won: + # TP hit early check: if trade lasted < 30% estimated time + open_time = entry.get("timestamp") or entry.get("open_time", "") + close_time = outcome.get("close_time", "") + if open_time and close_time: + try: + t_open = datetime.fromisoformat(str(open_time).replace("Z", "+00:00")) + t_close = datetime.fromisoformat(str(close_time).replace("Z", "+00:00")) + duration_min = (t_close - t_open).total_seconds() / 60 + # If TP hit in under 15 min, widen TP + if duration_min < 15: + pair_cfg["atr_tp_multiplier"] = min(3.0, pair_cfg["atr_tp_multiplier"] + 0.05) + changed = True + except (ValueError, TypeError): + pass + else: + # SL hit: check if price reversed to profit within 2x ATR + if atr_pips > 0 and pnl_pips <= atr_pips * 1.5: + # SL was reasonable, no change + pass + elif atr_pips > 0 and pnl_pips > 2 * atr_pips: + # SL too tight — widen + pair_cfg["atr_sl_multiplier"] = min(2.0, pair_cfg["atr_sl_multiplier"] + 0.05) + changed = True + + pair_cfg["sample_size"] = pair_cfg.get("sample_size", 0) + 1 + pair_cfg["last_updated"] = datetime.now(timezone.utc).isoformat() + config[pair] = pair_cfg + + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(config, indent=2)) + + return pair_cfg if changed else None + + +def _parse_ts(ts_str: str) -> float: + """Parse an ISO timestamp to epoch seconds, return 0 on failure.""" + try: + return datetime.fromisoformat(ts_str.replace("Z", "+00:00")).timestamp() + except (ValueError, TypeError): + return 0.0 diff --git a/src/scanner/automation/observation_log.py b/src/scanner/automation/observation_log.py new file mode 100644 index 0000000..4d608bd --- /dev/null +++ b/src/scanner/automation/observation_log.py @@ -0,0 +1,131 @@ +"""Observation logging for interesting market patterns (even when no trade is taken). + +US-008: Add observation logging for interesting market patterns. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +OBSERVATIONS_PATH = Path("trained_data/observations.jsonl") + +OBSERVATION_CATEGORIES = [ + "regime_change", + "unusual_spread", + "agent_disagreement", + "near_miss", + "correlation_break", +] + + +class ObservationLog: + """Captures interesting market observations during scans.""" + + def __init__(self, log_path: Optional[Path] = None): + self.log_path = log_path or OBSERVATIONS_PATH + + def log_observation( + self, + pair: str, + category: str, + description: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + """Append an observation to trained_data/observations.jsonl.""" + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "pair": pair, + "category": category, + "description": description, + "metadata": metadata or {}, + } + self.log_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.log_path, "a") as f: + f.write(json.dumps(entry, default=str) + "\n") + logger.debug("Observation: %s %s — %s", pair, category, description) + + def log_from_analysis(self, analysis: Any) -> int: + """Detect and log observations from a scan analysis result. + + Checks for: + 1. Regime change (volatility regime != historical mode) + 2. Agent disagreement (agents split ~50/50) + 3. Near miss (confidence within 2% of threshold) + + Returns count of observations logged. + """ + count = 0 + pair = getattr(analysis, "pair", "UNKNOWN") + + # Agent disagreement: agents split roughly 50/50 + agent_votes = getattr(analysis, "agent_votes", 0) + agent_total = getattr(analysis, "agent_total", 0) + if agent_total > 0 and agent_votes == agent_total // 2: + self.log_observation( + pair=pair, + category="agent_disagreement", + description=f"Agents split {agent_votes}/{agent_total}", + metadata={"votes": agent_votes, "total": agent_total}, + ) + count += 1 + + # Near miss: confidence within 2% of min_confidence + confidence = getattr(analysis, "confidence", 0) or 0 + min_conf = 0.55 # default threshold + if not getattr(analysis, "is_tradeable", False) and abs(confidence - min_conf) < 0.02: + self.log_observation( + pair=pair, + category="near_miss", + description=f"Confidence {confidence:.0%} within 2% of threshold {min_conf:.0%}", + metadata={"confidence": confidence, "threshold": min_conf}, + ) + count += 1 + + # Regime observation + regime = str(getattr(analysis, "volatility_regime", "") or "") + if regime in ("EXTREME", "HIGH"): + self.log_observation( + pair=pair, + category="regime_change", + description=f"Volatility regime: {regime}", + metadata={"regime": regime}, + ) + count += 1 + + return count + + def get_recent( + self, + pair: Optional[str] = None, + category: Optional[str] = None, + limit: int = 20, + ) -> List[Dict[str, Any]]: + """Query recent observations with optional filters.""" + if not self.log_path.exists(): + return [] + + results: List[Dict[str, Any]] = [] + for line in reversed(self.log_path.read_text().strip().split("\n")): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if pair and entry.get("pair") != pair: + continue + if category and entry.get("category") != category: + continue + + results.append(entry) + if len(results) >= limit: + break + + return results diff --git a/src/scanner/automation/state_engine.py b/src/scanner/automation/state_engine.py new file mode 100644 index 0000000..2fe770c --- /dev/null +++ b/src/scanner/automation/state_engine.py @@ -0,0 +1,139 @@ +"""Session state engine for cross-session continuity. + +Persists trading state between sessions so the next session can resume +intelligently without re-discovering context. + +US-002: Create session state engine for cross-session continuity. +""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +STATE_PATH = Path(".claude/state.json") + +_DEFAULT_STATE: Dict[str, Any] = { + "goal": "", + "status": "ready", + "done": [], + "next": "", + "open_questions": [], + "last_updated": "", + "portfolio_snapshot": { + "nav": 0.0, + "open_trades": 0, + "total_realized_pnl": 0.0, + "session_trades": 0, + "session_wins": 0, + "session_losses": 0, + "win_rate": 0.0, + }, + "improvement_focus": "", +} + + +class StateEngine: + """Manages .claude/state.json for cross-session continuity.""" + + def __init__(self, state_path: Optional[Path] = None): + self.state_path = state_path or STATE_PATH + + def load_state(self) -> Dict[str, Any]: + """Read .claude/state.json and return dict (or empty default if missing).""" + if not self.state_path.exists(): + return dict(_DEFAULT_STATE) + try: + data = json.loads(self.state_path.read_text()) + return data + except Exception as e: + logger.warning(f"Failed to load state: {e}") + return dict(_DEFAULT_STATE) + + def save_state( + self, + goal: str, + status: str, + done: List[str], + next_action: str, + open_questions: Optional[List[str]] = None, + portfolio: Optional[Dict[str, Any]] = None, + improvement_focus: str = "", + ) -> None: + """Write state to .claude/state.json.""" + state = { + "goal": goal, + "status": status, + "done": done, + "next": next_action, + "open_questions": open_questions or [], + "last_updated": datetime.now(timezone.utc).isoformat(), + "portfolio_snapshot": portfolio or _DEFAULT_STATE["portfolio_snapshot"], + "improvement_focus": improvement_focus, + } + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text(json.dumps(state, indent=2, default=str)) + logger.info("State saved to %s", self.state_path) + + def update_portfolio_snapshot(self) -> Dict[str, Any]: + """Fetch NAV from OANDA and open trade count, update state.""" + import requests + + state = self.load_state() + token = os.getenv("OANDA_API_TOKEN", "") + acct = os.getenv("OANDA_ACCOUNT_ID", "") + base = "https://api-fxpractice.oanda.com" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + snapshot = state.get("portfolio_snapshot", dict(_DEFAULT_STATE["portfolio_snapshot"])) + + try: + resp = requests.get( + f"{base}/v3/accounts/{acct}/summary", + headers=headers, + timeout=10, + ) + if resp.status_code == 200: + acct_data = resp.json().get("account", {}) + snapshot["nav"] = float(acct_data.get("NAV", 0)) + snapshot["open_trades"] = int(acct_data.get("openTradeCount", 0)) + snapshot["total_realized_pnl"] = float(acct_data.get("pl", 0)) + except Exception as e: + logger.warning(f"Failed to fetch OANDA account summary: {e}") + + # Update trade stats from journal + try: + journal_path = Path("trained_data/trade_journal_rl.json") + if journal_path.exists(): + entries = json.loads(journal_path.read_text()) + closed = [e for e in entries if e.get("outcome") is not None] + wins = sum(1 for e in closed if e["outcome"].get("trade_won", False)) + losses = len(closed) - wins + snapshot["session_trades"] = len(closed) + snapshot["session_wins"] = wins + snapshot["session_losses"] = losses + snapshot["win_rate"] = round(wins / len(closed), 2) if closed else 0.0 + except Exception as e: + logger.debug(f"Journal stats error: {e}") + + state["portfolio_snapshot"] = snapshot + state["last_updated"] = datetime.now(timezone.utc).isoformat() + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text(json.dumps(state, indent=2, default=str)) + logger.info("Portfolio snapshot updated: NAV=%.2f, open=%d", snapshot["nav"], snapshot["open_trades"]) + return snapshot + + def increment_scan_cycle(self) -> int: + """Increment and return the scan cycle count (stored in state).""" + state = self.load_state() + count = state.get("scan_cycle_count", 0) + 1 + state["scan_cycle_count"] = count + state["last_updated"] = datetime.now(timezone.utc).isoformat() + self.state_path.write_text(json.dumps(state, indent=2, default=str)) + return count diff --git a/src/scanner/config.py b/src/scanner/config.py index 5ca1f91..35c46cb 100644 --- a/src/scanner/config.py +++ b/src/scanner/config.py @@ -127,6 +127,8 @@ "enable_pair_performance_agent": True, # Minimum risk:reward ratio to execute a trade "min_risk_reward_ratio": 1.2, + # LLM deep analysis for losing trades + "enable_llm_trade_analysis": True, }, } VALID_SCAN_PROFILES = tuple(SCAN_PROFILES.keys()) @@ -357,6 +359,7 @@ class ScannerConfig: # --- Learning Loop Config (post-trade agent weight updates) --- enable_agent_learning: bool = True + enable_llm_trade_analysis: bool = False # LLM deep analysis for losing trades (US-009) weight_boost_on_win: float = 0.1 # Weight increase on winning trade weight_penalty_on_loss: float = 0.15 # Weight decrease on losing trade diff --git a/src/scanner/engine.py b/src/scanner/engine.py index 5f9554b..b9d55e0 100644 --- a/src/scanner/engine.py +++ b/src/scanner/engine.py @@ -1393,14 +1393,29 @@ def _scan_pair(self, pair: str) -> Optional[PairAnalysis]: metrics = self._calculate_metrics(df_feat, pair) # Dynamic ATR-based SL/TP (replaces fixed 15/30 defaults) + # Per-pair adaptive multipliers (US-006) override global config + _pair_sl_mult = self.config.atr_sl_multiplier + _pair_tp_mult = self.config.atr_tp_multiplier + try: + import json as _json + from pathlib import Path as _Path + _pair_cfg_path = _Path("trained_data/models/pair_sl_tp_config.json") + if _pair_cfg_path.exists(): + _pair_cfg = _json.loads(_pair_cfg_path.read_text()) + if pair in _pair_cfg: + _pair_sl_mult = _pair_cfg[pair].get("atr_sl_multiplier", _pair_sl_mult) + _pair_tp_mult = _pair_cfg[pair].get("atr_tp_multiplier", _pair_tp_mult) + except Exception: + pass + if atr_pips > 0: sl_pips = max( self.config.min_sl_pips, - min(atr_pips * self.config.atr_sl_multiplier, self.config.max_sl_pips), + min(atr_pips * _pair_sl_mult, self.config.max_sl_pips), ) tp_pips = max( self.config.min_tp_pips, - min(atr_pips * self.config.atr_tp_multiplier, self.config.max_tp_pips), + min(atr_pips * _pair_tp_mult, self.config.max_tp_pips), ) # High-confidence TP bonus if confidence >= self.config.high_prob_threshold: