diff --git a/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json b/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json index 3dc2c1ed5..9ea20a825 100644 --- a/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json +++ b/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json @@ -15,7 +15,7 @@ "horizon": "5m", "time_cohort_boundary_ms": 0, "prompt_snapshot_id": "sha256:2b55ba0e724dfc9f5a040911e397058a09c6229e42064e4998739d617b368dcb", - "search_policy_snapshot_id": "sha256:12579dd6aaf0e34e26faa25f72ffe30afc88bbb08cb494321560f9ec4b28b494", + "search_policy_snapshot_id": "sha256:5d15126651a93b08bfb60922b7a7957ff3bdae4ae31220c43008c41e70b65184", "search_budget": { "max_candidates": 6, "max_llm_calls": 2, diff --git a/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json b/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json index f3dadaf2c..edee7e575 100644 --- a/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json +++ b/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json @@ -15,7 +15,7 @@ "horizon": "5m", "time_cohort_boundary_ms": 0, "prompt_snapshot_id": "sha256:0816ebccf4c75ee6bdcfe315b253c84ddf1808a38687ebe3b924b87bd72a52a9", - "search_policy_snapshot_id": "sha256:12579dd6aaf0e34e26faa25f72ffe30afc88bbb08cb494321560f9ec4b28b494", + "search_policy_snapshot_id": "sha256:5d15126651a93b08bfb60922b7a7957ff3bdae4ae31220c43008c41e70b65184", "search_budget": { "max_candidates": 6, "max_llm_calls": 2, diff --git a/rust_hft/prediction-markets/crates/ploy-research/examples/persist_research_trace.rs b/rust_hft/prediction-markets/crates/ploy-research/examples/persist_research_trace.rs index 5dd4f393b..8a2f58094 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/examples/persist_research_trace.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/examples/persist_research_trace.rs @@ -1092,6 +1092,7 @@ fn group_factor_registry_rows_into_alpha_zoo_snapshot( AlphaZooSnapshot { version: ALPHA_ZOO_SNAPSHOT_VERSION.to_string(), target: target.to_string(), + side: None, entries, } } diff --git a/rust_hft/prediction-markets/crates/ploy-research/src/alpha_search.rs b/rust_hft/prediction-markets/crates/ploy-research/src/alpha_search.rs index 4458dc2ee..2ec672d26 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/src/alpha_search.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/src/alpha_search.rs @@ -8,12 +8,16 @@ use crate::autofactor::{ autofactor_runtime_contract_catalog, autofactor_target_horizon, factor_expr_hash, AutoFactorDecision, AutoFactorOptions, AutoFactorReport, FactorExpr, LlmPriorSpec, }; +use crate::factors_v2::ReviewSide; -const ALPHA_SEARCH_ARTIFACT_VERSION: &str = "alpha_search_artifacts_v1"; +pub const ALPHA_SEARCH_ARTIFACT_VERSION: &str = "alpha_search_artifacts_v1"; +pub const SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION: &str = "alpha_search_artifacts_v2"; #[derive(Debug, Clone, Serialize)] pub struct AlphaSearchArtifactSummary { pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, pub output_dir: String, pub candidate_count: usize, pub rejected_count: usize, @@ -22,6 +26,12 @@ pub struct AlphaSearchArtifactSummary { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlphaSearchRuntimeFeedback { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, pub runtime_score: String, pub base_factor: String, pub entry_signals: usize, @@ -55,6 +65,8 @@ pub struct MctsSearchStateArtifact { pub version: String, pub mode: String, pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, pub total_visits: usize, #[serde(default)] pub backpropagation_truncated_count: usize, @@ -90,6 +102,8 @@ pub struct MctsSearchStateNode { pub struct AlphaZooSnapshot { pub version: String, pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, pub entries: Vec, } @@ -111,6 +125,7 @@ pub struct SubtreeFrequencyState { pub enum AlphaSearchArtifactError { Io(std::io::Error), Json(serde_json::Error), + IdentityMismatch(String), } impl fmt::Display for AlphaSearchArtifactError { @@ -118,6 +133,9 @@ impl fmt::Display for AlphaSearchArtifactError { match self { Self::Io(err) => write!(f, "alpha search artifact I/O failed: {err}"), Self::Json(err) => write!(f, "alpha search artifact JSON failed: {err}"), + Self::IdentityMismatch(reason) => { + write!(f, "alpha search artifact identity mismatch: {reason}") + } } } } @@ -141,6 +159,8 @@ struct SearchSpaceArtifact { version: &'static str, mode: &'static str, target: String, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, feature_pool: Vec, constant_pool: Vec, operator_pool: Vec<&'static str>, @@ -165,6 +185,8 @@ struct LlmPriorArtifact { version: &'static str, mode: &'static str, target: String, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, hypotheses: Vec, allowed_mutation_types: Vec<&'static str>, note: &'static str, @@ -182,6 +204,8 @@ struct PriorHypothesis { struct CandidateExpression { name: String, target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, source: &'static str, complexity: usize, root_gene: String, @@ -193,6 +217,8 @@ struct CandidateExpression { struct RejectedExpression { name: String, target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, root_gene: String, structural_signature: String, reason: String, @@ -204,6 +230,8 @@ struct TreeTraceArtifact { version: &'static str, mode: &'static str, target: String, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, nodes: Vec, } @@ -225,6 +253,8 @@ struct NodeMetric { factor_name: String, parent_name: Option, target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, decision: String, reason: String, selected_dimension: String, @@ -259,6 +289,8 @@ struct NodeMetric { #[derive(Debug, Serialize)] struct AvoidedSubtree { + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, root_gene: String, structural_signature: String, depth: usize, @@ -272,6 +304,8 @@ struct SearchFeedbackArtifact { version: &'static str, mode: &'static str, target: String, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, candidate_count: usize, rejected_count: usize, watchlist_count: usize, @@ -285,6 +319,8 @@ struct SearchFeedbackArtifact { #[derive(Debug, Serialize)] struct RuntimeFeedbackSummary { + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, runtime_score: String, base_factor: String, entry_signals: usize, @@ -319,6 +355,8 @@ struct RuntimeAvoidance { struct FactorRegistryPreviewRow { factor_name: String, target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, horizon: String, dsl_hash: String, ast_json: serde_json::Value, @@ -332,6 +370,8 @@ struct FactorRegistryPreviewRow { struct FactorRegistryPreviewArtifact { version: &'static str, target: String, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, horizon: String, factors: Vec, } @@ -355,6 +395,8 @@ struct MctsExpansionPlan { version: &'static str, mode: &'static str, target: String, + #[serde(skip_serializing_if = "Option::is_none")] + side: Option, exploration_weight: f64, selected_nodes: Vec, note: &'static str, @@ -429,7 +471,95 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( llm_prior: Option<&LlmPriorSpec>, alpha_zoo: Option<&AlphaZooSnapshot>, ) -> Result { - let output_dir = output_root.as_ref().join(target); + if is_side_bound_repricing_target(target) + || reports.iter().any(|report| report.side.is_some()) + || prior_state.is_some_and(|state| { + state.version == SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION || state.side.is_some() + }) + || runtime_feedback.is_some_and(|feedback| { + feedback.version.as_deref() == Some(SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION) + || feedback.side.is_some() + }) + || alpha_zoo.is_some_and(|zoo| { + zoo.version == SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION || zoo.side.is_some() + }) + { + return Err(AlphaSearchArtifactError::IdentityMismatch( + "side-bound repricing inputs require the side-bound writer".to_string(), + )); + } + write_alpha_search_artifacts_core( + output_root, + target, + None, + input_names, + reports, + options, + prior_state, + runtime_feedback, + llm_prior, + alpha_zoo, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn write_side_bound_alpha_search_artifacts_with_state_and_runtime_feedback( + output_root: impl AsRef, + target: &str, + side: ReviewSide, + input_names: &[String], + reports: &[AutoFactorReport], + options: &AutoFactorOptions, + prior_state: Option<&MctsSearchStateArtifact>, + runtime_feedback: Option<&AlphaSearchRuntimeFeedback>, + alpha_zoo: Option<&AlphaZooSnapshot>, +) -> Result { + write_alpha_search_artifacts_core( + output_root, + target, + Some(side), + input_names, + reports, + options, + prior_state, + runtime_feedback, + None, + alpha_zoo, + ) +} + +#[allow(clippy::too_many_arguments)] +fn write_alpha_search_artifacts_core( + output_root: impl AsRef, + target: &str, + side: Option, + input_names: &[String], + reports: &[AutoFactorReport], + options: &AutoFactorOptions, + prior_state: Option<&MctsSearchStateArtifact>, + runtime_feedback: Option<&AlphaSearchRuntimeFeedback>, + llm_prior: Option<&LlmPriorSpec>, + alpha_zoo: Option<&AlphaZooSnapshot>, +) -> Result { + if let Some(side) = side { + validate_side_bound_inputs( + target, + side, + reports, + prior_state, + runtime_feedback, + alpha_zoo, + )?; + } + let version = if side.is_some() { + SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION + } else { + ALPHA_SEARCH_ARTIFACT_VERSION + }; + let output_dir = side.map_or_else( + || output_root.as_ref().join(target), + |side| output_root.as_ref().join(target).join(side.as_str()), + ); std::fs::create_dir_all(&output_dir)?; let feature_pool = { @@ -440,9 +570,10 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( write_json( &output_dir.join("search-space.json"), &SearchSpaceArtifact { - version: ALPHA_SEARCH_ARTIFACT_VERSION, + version, mode: "deterministic_seed_search", target: target.to_string(), + side, feature_pool, constant_pool: vec![ 0.001, 0.005, 0.01, 0.02, 0.05, 0.10, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0, 60.0, 300.0, @@ -483,9 +614,10 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( write_json( &output_dir.join("llm-priors.json"), &LlmPriorArtifact { - version: ALPHA_SEARCH_ARTIFACT_VERSION, + version, mode: "deterministic_domain_prior_placeholder", target: target.to_string(), + side, hypotheses: default_hypotheses(target), allowed_mutation_types: vec![ "add_feature_gate", @@ -507,6 +639,7 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( .map(|report| CandidateExpression { name: report.name.clone(), target: report.target.clone(), + side: report.side, source: candidate_source(&report.name), complexity: report.complexity, root_gene: root_gene(&report.expr), @@ -522,6 +655,7 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( .map(|report| RejectedExpression { name: report.name.clone(), target: report.target.clone(), + side: report.side, root_gene: root_gene(&report.expr), structural_signature: structural_signature(&report.expr), reason: report.reason.clone(), @@ -548,9 +682,16 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( write_json(&output_dir.join("node-metrics.json"), &node_metrics)?; write_json( &output_dir.join("factor-registry-preview.json"), - &factor_registry_preview_artifact(target, reports, &node_metrics)?, + &factor_registry_preview_artifact(version, target, side, reports, &node_metrics)?, )?; - let mcts_state = mcts_search_state(target, &node_metrics, prior_state, subtree_frequencies); + let mcts_state = mcts_search_state( + version, + target, + side, + &node_metrics, + prior_state, + subtree_frequencies, + ); let prior_truncated_count = prior_state .filter(|state| state.target == target) .map(|state| state.backpropagation_truncated_count) @@ -567,15 +708,16 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( write_json(&output_dir.join("mcts-state.json"), &mcts_state)?; write_json( &output_dir.join("mcts-expansion-plan.json"), - &mcts_expansion_plan(target, &node_metrics, &mcts_state), + &mcts_expansion_plan(version, target, side, &node_metrics, &mcts_state), )?; write_json( &output_dir.join("tree-trace.json"), &TreeTraceArtifact { - version: ALPHA_SEARCH_ARTIFACT_VERSION, + version, mode: "single_depth_seed_tree", target: target.to_string(), + side, nodes: reports .iter() .enumerate() @@ -600,16 +742,17 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( write_json( &output_dir.join("avoided-subtrees.json"), - &avoided_subtrees(&mcts_state.subtree_frequencies), + &avoided_subtrees(side, &mcts_state.subtree_frequencies), )?; let best = node_metrics .iter() .max_by(|lhs, rhs| lhs.reward.total_cmp(&rhs.reward)); let feedback = SearchFeedbackArtifact { - version: ALPHA_SEARCH_ARTIFACT_VERSION, + version, mode: "deterministic_seed_search", target: target.to_string(), + side, candidate_count: reports.len(), rejected_count: rejected.len(), watchlist_count: reports @@ -623,6 +766,7 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( best_candidate: best.map(|metric| metric.factor_name.clone()), best_reward: best.map(|metric| metric.reward), runtime_feedback: runtime_feedback.map(|feedback| RuntimeFeedbackSummary { + side: feedback.side, runtime_score: feedback.runtime_score.clone(), base_factor: feedback.base_factor.clone(), entry_signals: feedback.entry_signals, @@ -647,6 +791,7 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( Ok(AlphaSearchArtifactSummary { target: target.to_string(), + side, output_dir: output_dir.display().to_string(), candidate_count: reports.len(), rejected_count: rejected.len(), @@ -654,6 +799,93 @@ pub fn write_alpha_search_artifacts_with_state_and_runtime_feedback( }) } +fn validate_side_bound_inputs( + target: &str, + side: ReviewSide, + reports: &[AutoFactorReport], + prior_state: Option<&MctsSearchStateArtifact>, + runtime_feedback: Option<&AlphaSearchRuntimeFeedback>, + alpha_zoo: Option<&AlphaZooSnapshot>, +) -> Result<(), AlphaSearchArtifactError> { + if !is_side_bound_repricing_target(target) { + return Err(AlphaSearchArtifactError::IdentityMismatch(format!( + "side-bound writer does not support target={target}" + ))); + } + for report in reports { + if report.target.as_deref() != Some(target) || report.side != Some(side) { + return Err(AlphaSearchArtifactError::IdentityMismatch(format!( + "report `{}` expected target={target} side={}, found target={} side={}", + report.name, + side.as_str(), + report.target.as_deref().unwrap_or(""), + report.side.map(ReviewSide::as_str).unwrap_or("") + ))); + } + } + if let Some(state) = prior_state { + validate_side_bound_identity( + "MCTS state", + Some(state.version.as_str()), + Some(state.target.as_str()), + state.side, + target, + side, + )?; + } + if let Some(feedback) = runtime_feedback { + validate_side_bound_identity( + "runtime feedback", + feedback.version.as_deref(), + feedback.target.as_deref(), + feedback.side, + target, + side, + )?; + } + if let Some(zoo) = alpha_zoo { + validate_side_bound_identity( + "Alpha Zoo", + Some(zoo.version.as_str()), + Some(zoo.target.as_str()), + zoo.side, + target, + side, + )?; + } + Ok(()) +} + +fn is_side_bound_repricing_target(target: &str) -> bool { + matches!( + target, + "full_depth_reprice_pnl_10s" | "full_depth_reprice_pnl_30s" + ) +} + +fn validate_side_bound_identity( + kind: &str, + version: Option<&str>, + target: Option<&str>, + side: Option, + expected_target: &str, + expected_side: ReviewSide, +) -> Result<(), AlphaSearchArtifactError> { + if version != Some(SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION) + || target != Some(expected_target) + || side != Some(expected_side) + { + return Err(AlphaSearchArtifactError::IdentityMismatch(format!( + "{kind} expected version={SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION} target={expected_target} side={}, found version={} target={} side={}", + expected_side.as_str(), + version.unwrap_or(""), + target.unwrap_or(""), + side.map(ReviewSide::as_str).unwrap_or("") + ))); + } + Ok(()) +} + fn factor_registry_preview_rows( target: &str, reports: &[AutoFactorReport], @@ -672,6 +904,7 @@ fn factor_registry_preview_rows( Ok(FactorRegistryPreviewRow { factor_name: report.name.clone(), target: report.target.clone(), + side: report.side, horizon: horizon.clone(), dsl_hash, ast_json, @@ -685,13 +918,16 @@ fn factor_registry_preview_rows( } fn factor_registry_preview_artifact( + version: &'static str, target: &str, + side: Option, reports: &[AutoFactorReport], node_metrics: &[NodeMetric], ) -> Result { Ok(FactorRegistryPreviewArtifact { - version: ALPHA_SEARCH_ARTIFACT_VERSION, + version, target: target.to_string(), + side, horizon: factor_horizon(target), factors: factor_registry_preview_rows(target, reports, node_metrics)?, }) @@ -1135,6 +1371,7 @@ fn node_metric( factor_name: report.name.clone(), parent_name: report.parent_name.clone(), target: report.target.clone(), + side: report.side, decision: report.decision.as_str().to_string(), reason: report.reason.clone(), selected_dimension: selected_dimension(report, runtime_avoidances), @@ -1189,7 +1426,9 @@ fn node_metric( } fn mcts_search_state( + version: &str, target: &str, + side: Option, metrics: &[NodeMetric], prior_state: Option<&MctsSearchStateArtifact>, subtree_frequencies: Vec, @@ -1245,9 +1484,10 @@ fn mcts_search_state( nodes.sort_by(|lhs, rhs| lhs.factor_name.cmp(&rhs.factor_name)); let total_visits = nodes.iter().map(|node| node.visits).sum(); MctsSearchStateArtifact { - version: ALPHA_SEARCH_ARTIFACT_VERSION.to_string(), + version: version.to_string(), mode: "cumulative_ucb_state".to_string(), target: target.to_string(), + side, total_visits, backpropagation_truncated_count, nodes, @@ -1285,7 +1525,9 @@ fn backpropagate( } fn mcts_expansion_plan( + version: &'static str, target: &str, + side: Option, metrics: &[NodeMetric], state: &MctsSearchStateArtifact, ) -> MctsExpansionPlan { @@ -1322,9 +1564,10 @@ fn mcts_expansion_plan( selected.truncate(12); MctsExpansionPlan { - version: ALPHA_SEARCH_ARTIFACT_VERSION, + version, mode: "single_run_ucb_planner", target: target.to_string(), + side, exploration_weight, selected_nodes: selected, note: "MCTS state accumulates leaf visits and backpropagates leaf rewards through recorded parent lineage; this plan selects branches for the next bounded search run with UCB priority.", @@ -1539,11 +1782,15 @@ fn subtree_frequency_state( .collect() } -fn avoided_subtrees(frequencies: &[SubtreeFrequencyState]) -> Vec { +fn avoided_subtrees( + side: Option, + frequencies: &[SubtreeFrequencyState], +) -> Vec { frequencies .iter() .filter(|item| item.count > 2) .map(|item| AvoidedSubtree { + side, root_gene: item.root_gene.clone(), structural_signature: item.structural_signature.clone(), depth: item.depth, @@ -2019,6 +2266,7 @@ mod tests { AutoFactorReport { name: name.to_string(), target: Some("full_depth_settlement_executable_pnl".to_string()), + side: None, expr: FactorExpr::Input("conservative_settlement_edge".to_string()), n: 100, pearson_ic: 0.2, @@ -2142,7 +2390,7 @@ mod tests { let frequencies = subtree_frequency_state("full_depth_settlement_executable_pnl", &reports, None, None); - let avoided = avoided_subtrees(&frequencies); + let avoided = avoided_subtrees(None, &frequencies); let inner_signature = structural_signature(&inner); let subtree = avoided .iter() @@ -2173,6 +2421,7 @@ mod tests { version: ALPHA_SEARCH_ARTIFACT_VERSION.to_string(), mode: "cumulative_ucb_state".to_string(), target: "full_depth_settlement_executable_pnl".to_string(), + side: None, total_visits: 0, backpropagation_truncated_count: 0, nodes: Vec::new(), @@ -2239,6 +2488,7 @@ mod tests { let report = AutoFactorReport { name: "auto_settlement_conservative_settlement_edge".to_string(), target: Some("full_depth_settlement_executable_pnl".to_string()), + side: None, expr: FactorExpr::Input("conservative_settlement_edge".to_string()), n: 100, pearson_ic: 0.2, @@ -2297,6 +2547,7 @@ mod tests { .expect("preview json"); assert_eq!(preview["version"], ALPHA_SEARCH_ARTIFACT_VERSION); assert_eq!(preview["target"], "full_depth_settlement_executable_pnl"); + assert!(preview.get("side").is_none()); assert_eq!(preview["horizon"], "5m"); let rows = preview["factors"].as_array().expect("factors array"); assert_eq!( @@ -2329,6 +2580,78 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + #[test] + fn side_bound_artifacts_do_not_overwrite_and_reject_wrong_side_state() { + let temp = tempfile::tempdir().expect("create isolated artifact directory"); + let tmp = temp.path(); + let target = "full_depth_reprice_pnl_10s"; + let report_for = |side| AutoFactorReport { + target: Some(target.to_string()), + side: Some(side), + expr: FactorExpr::Input("repricing_gap_side_10s".to_string()), + ..sample_report("repricing_gap_side_10s") + }; + let write = |root: &Path, + side, + report: &AutoFactorReport, + state: Option<&MctsSearchStateArtifact>| { + write_side_bound_alpha_search_artifacts_with_state_and_runtime_feedback( + root, + target, + side, + &["repricing_gap_side_10s".to_string()], + std::slice::from_ref(report), + &AutoFactorOptions::default(), + state, + None, + None, + ) + }; + + let up = report_for(ReviewSide::Up); + let up_summary = write(tmp, ReviewSide::Up, &up, None).expect("write Up artifacts"); + let up_search_space = tmp.join(target).join("up/search-space.json"); + let up_before = std::fs::read(&up_search_space).expect("read Up search space"); + + let down = report_for(ReviewSide::Down); + write(tmp, ReviewSide::Down, &down, None).expect("write Down artifacts"); + + assert_eq!(up_summary.side, Some(ReviewSide::Up)); + assert_eq!( + std::fs::read(&up_search_space).expect("reread Up search space"), + up_before + ); + assert!(tmp.join(target).join("down/search-space.json").exists()); + + let pooled_root = tmp.join("pooled"); + let err = write_alpha_search_artifacts( + &pooled_root, + target, + &["repricing_gap_side_10s".to_string()], + std::slice::from_ref(&up), + &AutoFactorOptions::default(), + ) + .expect_err("side-bound reports must not use the pooled writer"); + assert!(matches!(err, AlphaSearchArtifactError::IdentityMismatch(_))); + assert!(!pooled_root.join(target).exists()); + + let wrong_side_state = MctsSearchStateArtifact { + version: SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION.to_string(), + mode: "cumulative_ucb_state".to_string(), + target: target.to_string(), + side: Some(ReviewSide::Down), + total_visits: 0, + backpropagation_truncated_count: 0, + nodes: Vec::new(), + subtree_frequencies: Vec::new(), + }; + let mismatch_root = tmp.join("mismatch"); + let err = write(&mismatch_root, ReviewSide::Up, &up, Some(&wrong_side_state)) + .expect_err("wrong-side prior state must fail closed"); + assert!(matches!(err, AlphaSearchArtifactError::IdentityMismatch(_))); + assert!(!mismatch_root.join(target).join("up").exists()); + } + #[test] fn runtime_contract_canonicalizes_supported_research_inputs() { let report = AutoFactorReport { @@ -2509,6 +2832,7 @@ mod tests { let report = AutoFactorReport { name: "auto_settlement_conservative_settlement_edge".to_string(), target: Some("full_depth_settlement_executable_pnl".to_string()), + side: None, expr: FactorExpr::Input("conservative_settlement_edge".to_string()), n: 100, pearson_ic: 0.2, @@ -2542,6 +2866,7 @@ mod tests { version: ALPHA_SEARCH_ARTIFACT_VERSION.to_string(), mode: "cumulative_ucb_state".to_string(), target: "full_depth_settlement_executable_pnl".to_string(), + side: None, total_visits: 3, backpropagation_truncated_count: 0, nodes: vec![MctsSearchStateNode { @@ -2605,7 +2930,9 @@ mod tests { .map(|metric| (metric.factor_name.as_str(), metric.reward)) .collect::>(); let state = mcts_search_state( + ALPHA_SEARCH_ARTIFACT_VERSION, "full_depth_settlement_executable_pnl", + None, &metrics, None, Vec::new(), @@ -2661,7 +2988,9 @@ mod tests { .collect::>(); let expected_root_total = metrics.iter().map(|metric| metric.reward).sum::(); let state = mcts_search_state( + ALPHA_SEARCH_ARTIFACT_VERSION, "full_depth_settlement_executable_pnl", + None, &metrics, None, Vec::new(), @@ -2693,6 +3022,7 @@ mod tests { version: ALPHA_SEARCH_ARTIFACT_VERSION.to_string(), mode: "cumulative_ucb_state".to_string(), target: "full_depth_settlement_executable_pnl".to_string(), + side: None, total_visits: 0, backpropagation_truncated_count: 0, nodes: vec![MctsSearchStateNode { @@ -2709,7 +3039,9 @@ mod tests { }; let state = mcts_search_state( + ALPHA_SEARCH_ARTIFACT_VERSION, "full_depth_settlement_executable_pnl", + None, &metrics, Some(&prior), Vec::new(), @@ -2747,12 +3079,20 @@ mod tests { }) .collect::>(); let state = mcts_search_state( + ALPHA_SEARCH_ARTIFACT_VERSION, "full_depth_settlement_executable_pnl", + None, &metrics, None, subtree_frequencies.clone(), ); - let plan = mcts_expansion_plan("full_depth_settlement_executable_pnl", &metrics, &state); + let plan = mcts_expansion_plan( + ALPHA_SEARCH_ARTIFACT_VERSION, + "full_depth_settlement_executable_pnl", + None, + &metrics, + &state, + ); assert_eq!( plan.selected_nodes @@ -2814,6 +3154,9 @@ mod tests { alternative.top_bucket_avg_label = 0.35; let feedback = AlphaSearchRuntimeFeedback { + version: None, + target: None, + side: None, runtime_score: "autofactor_formula:mut_spread_adjusted_external_move_near_strike" .to_string(), base_factor: "mut_spread_adjusted_external_move_near_strike".to_string(), @@ -2851,6 +3194,9 @@ mod tests { let alternative = sample_report("auto_settlement_full_depth_settlement_edge_x_capacity"); let feedback = AlphaSearchRuntimeFeedback { + version: None, + target: None, + side: None, runtime_score: "autofactor_formula:mut_spread_adjusted_external_move_near_strike" .to_string(), base_factor: "mut_spread_adjusted_external_move_near_strike".to_string(), @@ -2871,12 +3217,20 @@ mod tests { }) .collect::>(); let state = mcts_search_state( + ALPHA_SEARCH_ARTIFACT_VERSION, "full_depth_settlement_executable_pnl", + None, &metrics, None, subtree_frequencies, ); - let plan = mcts_expansion_plan("full_depth_settlement_executable_pnl", &metrics, &state); + let plan = mcts_expansion_plan( + ALPHA_SEARCH_ARTIFACT_VERSION, + "full_depth_settlement_executable_pnl", + None, + &metrics, + &state, + ); assert!(!plan .selected_nodes @@ -2930,12 +3284,20 @@ mod tests { }) .collect::>(); let state = mcts_search_state( + ALPHA_SEARCH_ARTIFACT_VERSION, "full_depth_settlement_executable_pnl", + None, &metrics, None, subtree_frequencies, ); - let plan = mcts_expansion_plan("full_depth_settlement_executable_pnl", &metrics, &state); + let plan = mcts_expansion_plan( + ALPHA_SEARCH_ARTIFACT_VERSION, + "full_depth_settlement_executable_pnl", + None, + &metrics, + &state, + ); assert!(plan .selected_nodes @@ -3018,6 +3380,7 @@ mod tests { let zoo = AlphaZooSnapshot { version: "alpha_zoo_v1".to_string(), target: "full_depth_settlement_executable_pnl".to_string(), + side: None, entries: vec![AlphaZooEntry { root_gene: root_gene(&report.expr), count: 50, @@ -3048,6 +3411,7 @@ mod tests { let zoo = AlphaZooSnapshot { version: "alpha_zoo_v1".to_string(), target: "full_depth_reprice_pnl_10s".to_string(), + side: None, entries: vec![AlphaZooEntry { root_gene: root_gene(&report.expr), count: 50, @@ -3082,6 +3446,7 @@ mod tests { let empty_zoo = AlphaZooSnapshot { version: "alpha_zoo_v1".to_string(), target: "full_depth_settlement_executable_pnl".to_string(), + side: None, entries: Vec::new(), }; let reward_with_empty_zoo = diff --git a/rust_hft/prediction-markets/crates/ploy-research/src/autofactor.rs b/rust_hft/prediction-markets/crates/ploy-research/src/autofactor.rs index a154a8035..d5028dc11 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/src/autofactor.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/src/autofactor.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::factors::{pearson_ic, spearman_ic}; -use crate::factors_v2::FactorObservationV2; +use crate::factors_v2::{FactorObservationV2, ReviewSide}; const EPS: f64 = 1e-9; const MAX_DETERMINISTIC_MUTATION_DEPTH: usize = 2; @@ -418,6 +418,8 @@ impl AutoFactorDecision { pub struct AutoFactorReport { pub name: String, pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, pub expr: FactorExpr, pub n: usize, pub pearson_ic: f64, @@ -455,8 +457,8 @@ pub struct AutoFactorReport { pub enum AutoFactorV2Target { RepricePnl10s, RepricePnl30s, - FullDepthRepricePnl10s, - FullDepthRepricePnl30s, + FullDepthRepricePnl10s(ReviewSide), + FullDepthRepricePnl30s(ReviewSide), SettlementExecutablePnl, FullDepthSettlementExecutablePnl, TradeableFullDepthSettlementPnl, @@ -467,8 +469,8 @@ impl AutoFactorV2Target { match self { AutoFactorV2Target::RepricePnl10s => "reprice_pnl_10s", AutoFactorV2Target::RepricePnl30s => "reprice_pnl_30s", - AutoFactorV2Target::FullDepthRepricePnl10s => "full_depth_reprice_pnl_10s", - AutoFactorV2Target::FullDepthRepricePnl30s => "full_depth_reprice_pnl_30s", + AutoFactorV2Target::FullDepthRepricePnl10s(_) => "full_depth_reprice_pnl_10s", + AutoFactorV2Target::FullDepthRepricePnl30s(_) => "full_depth_reprice_pnl_30s", AutoFactorV2Target::SettlementExecutablePnl => "settlement_executable_pnl", AutoFactorV2Target::FullDepthSettlementExecutablePnl => { "full_depth_settlement_executable_pnl" @@ -479,12 +481,25 @@ impl AutoFactorV2Target { } } + pub fn review_side(self) -> Option { + match self { + Self::FullDepthRepricePnl10s(side) | Self::FullDepthRepricePnl30s(side) => Some(side), + _ => None, + } + } + fn label(self, row: &FactorObservationV2) -> f64 { match self { AutoFactorV2Target::RepricePnl10s => row.label_future_exit_pnl_10s, AutoFactorV2Target::RepricePnl30s => row.label_future_exit_pnl_30s, - AutoFactorV2Target::FullDepthRepricePnl10s => row.label_future_exit_full_depth_pnl_10s, - AutoFactorV2Target::FullDepthRepricePnl30s => row.label_future_exit_full_depth_pnl_30s, + AutoFactorV2Target::FullDepthRepricePnl10s(side) if row.side == side => { + row.label_future_exit_full_depth_pnl_10s + } + AutoFactorV2Target::FullDepthRepricePnl30s(side) if row.side == side => { + row.label_future_exit_full_depth_pnl_30s + } + AutoFactorV2Target::FullDepthRepricePnl10s(_) + | AutoFactorV2Target::FullDepthRepricePnl30s(_) => None, AutoFactorV2Target::SettlementExecutablePnl => row.label_executable_pnl_15u, AutoFactorV2Target::FullDepthSettlementExecutablePnl => { row.label_full_depth_executable_pnl_15u @@ -666,6 +681,7 @@ pub fn evaluate_named_factor( Ok(AutoFactorReport { name: factor.name.clone(), target: factor.target.clone(), + side: None, expr: factor.expr.clone(), n: scored.len(), pearson_ic: pearson, @@ -751,14 +767,15 @@ pub fn format_autofactor_reports(reports: &[AutoFactorReport], top_n: usize) -> "target labels are side-aligned executable PnL for the requested target; reports are candidate discovery gates, not deploy decisions.\n", ); out.push_str( - "rank,name,target,decision,reason,n,spearman_ic,pearson_ic,window_count,icir,positive_window_ratio,symbol_count,symbol_positive_ratio,monotonicity,top_bucket_n,top_bucket_avg_label,top_bucket_positive_label_rate,top_bucket_full_depth_entry_fill_rate,top_bucket_avg_entry_sweep_slip_bps,top_bucket_avg_entry_sweep_levels,top_bucket_unique_event_count,top_bucket_max_event_decisions,complexity\n", + "rank,name,target,side,decision,reason,n,spearman_ic,pearson_ic,window_count,icir,positive_window_ratio,symbol_count,symbol_positive_ratio,monotonicity,top_bucket_n,top_bucket_avg_label,top_bucket_positive_label_rate,top_bucket_full_depth_entry_fill_rate,top_bucket_avg_entry_sweep_slip_bps,top_bucket_avg_entry_sweep_levels,top_bucket_unique_event_count,top_bucket_max_event_decisions,complexity\n", ); for (idx, report) in reports.iter().take(top_n).enumerate() { out.push_str(&format!( - "{},{},{},{},{},{},{:.6},{:.6},{},{:.6},{:.4},{},{:.4},{:.4},{},{:.6},{:.4},{:.4},{:.2},{:.2},{},{},{}\n", + "{},{},{},{},{},{},{},{:.6},{:.6},{},{:.6},{:.4},{},{:.4},{:.4},{},{:.6},{:.4},{:.4},{:.2},{:.2},{},{},{}\n", idx + 1, report.name, report.target.as_deref().unwrap_or(""), + report.side.map(ReviewSide::as_str).unwrap_or(""), report.decision.as_str(), report.reason, report.n, @@ -814,6 +831,21 @@ pub fn mine_domain_autofactors_from_v2_with_guidance( mcts_selected_factor_names: &[String], llm_prior: Option<&LlmPriorSpec>, ) -> Result, AutoFactorError> { + let side_rows = target.review_side().map(|side| { + rows.iter() + .filter(|row| row.side == side) + .cloned() + .collect::>() + }); + let rows = side_rows.as_deref().unwrap_or(rows); + if rows.is_empty() { + if let Some(side) = target.review_side() { + return Err(AutoFactorError::MissingInput(format!( + "review_side={}", + side.as_str() + ))); + } + } let matrix = autofactor_matrix_from_v2(rows)?; let labels = autofactor_labels_from_v2(rows, target); let windows = autofactor_windows_from_v2(rows); @@ -832,7 +864,7 @@ pub fn mine_domain_autofactors_from_v2_with_guidance( factor }) .collect::>(); - mine_autofactors_with_event_ids( + let mut reports = mine_autofactors_with_event_ids( &candidates, &matrix, &labels, @@ -840,7 +872,11 @@ pub fn mine_domain_autofactors_from_v2_with_guidance( &symbols, &event_ids, options, - ) + )?; + for report in &mut reports { + report.side = target.review_side(); + } + Ok(reports) } pub fn autofactor_matrix_from_v2( @@ -4010,7 +4046,7 @@ mod tests { let reports = mine_domain_autofactors_from_v2( &rows, - AutoFactorV2Target::FullDepthRepricePnl10s, + AutoFactorV2Target::FullDepthRepricePnl10s(ReviewSide::Up), &options, ) .expect("reports"); @@ -4020,6 +4056,66 @@ mod tests { .all(|report| !report.name.starts_with("auto_settlement_"))); } + #[test] + fn full_depth_repricing_mines_each_token_side_without_pooling() { + let rows = (0..80) + .flat_map(|idx| { + let up = synthetic_v2_row(idx); + let mut down = up.clone(); + down.side = ReviewSide::Down; + down.pm_token_id = "down-token".to_string(); + down.side_is_up = 0.0; + down.label_future_exit_full_depth_pnl_10s = + up.label_future_exit_full_depth_pnl_10s.map(|label| -label); + [up, down] + }) + .collect::>(); + let options = AutoFactorOptions { + min_observations: 40, + min_window_observations: 10, + min_icir: 0.1, + ..Default::default() + }; + + let report_for = |side| { + mine_domain_autofactors_from_v2( + &rows, + AutoFactorV2Target::FullDepthRepricePnl10s(side), + &options, + ) + .expect("side-bound reports") + .into_iter() + .find(|report| report.name == "repricing_gap_side_10s") + .expect("repricing gap report") + }; + let up = report_for(ReviewSide::Up); + let down = report_for(ReviewSide::Down); + + assert_eq!((up.n, up.side), (80, Some(ReviewSide::Up))); + assert_eq!((down.n, down.side), (80, Some(ReviewSide::Down))); + assert!(up.spearman_ic > 0.99); + assert!(down.spearman_ic < -0.99); + assert!(AutoFactorV2Target::FullDepthRepricePnl10s(ReviewSide::Up) + .label( + rows.iter() + .find(|row| row.side == ReviewSide::Down) + .unwrap() + ) + .is_nan()); + + let up_only = rows + .iter() + .filter(|row| row.side == ReviewSide::Up) + .cloned() + .collect::>(); + assert!(mine_domain_autofactors_from_v2( + &up_only, + AutoFactorV2Target::FullDepthRepricePnl10s(ReviewSide::Down), + &options, + ) + .is_err()); + } + #[test] fn mines_domain_candidates_from_v2_uses_requested_target_metadata() { let rows = (0..80).map(synthetic_v2_row).collect::>(); diff --git a/rust_hft/prediction-markets/crates/ploy-research/src/bin/monday-prediction-evaluator.rs b/rust_hft/prediction-markets/crates/ploy-research/src/bin/monday-prediction-evaluator.rs index 417d5537a..1a7af9ed3 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/src/bin/monday-prediction-evaluator.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/src/bin/monday-prediction-evaluator.rs @@ -32,7 +32,7 @@ use ploy_research::{ FactorObservation, FactorReviewOptions, FactorStabilityOptions, FactorWalkForwardOptions, FillabilityReviewOptions, FullDepthExecutionMatrixOptions, LiquidityGateV1Options, LiquidityGatedAlphaV1Options, LlmPriorSpec, MetaLabelWalkForwardOptions, RepricingIcOptions, - ResearchSnapshotRequest, SettlementProbabilityDataQualityMode, + ResearchSnapshotRequest, ReviewSide, SettlementProbabilityDataQualityMode, SettlementProbabilityPromotionGateOptions, SettlementProbabilityReportOptions, SettlementProbabilityTimeCohort, SettlementProbabilityWalkForwardOptions, TradeFormationReviewOptions, @@ -499,6 +499,9 @@ fn runtime_feedback_from_candidate_replay(path: &str) -> Option { let reports = filter_autofactor_reports(reports, options.factor_name_filter.as_deref()); - println!("# AutoFactor target={}", target.as_str()); + let side_suffix = side + .map(|side| format!(" side={}", side.as_str())) + .unwrap_or_default(); + println!("# AutoFactor target={target_name}{side_suffix}"); println!("{}", format_autofactor_reports(&reports, options.top_n)); if let (Some(output_dir), Some(input_names)) = ( alpha_search_output_dir.as_deref(), alpha_search_input_names.as_ref(), ) { - match write_alpha_search_artifacts_with_state_and_runtime_feedback( - output_dir, - target.as_str(), - input_names, - &reports, - &autofactor_options, - mcts_state.as_ref(), - runtime_feedback.as_ref(), - llm_prior.as_ref(), - alpha_zoo.as_ref(), - ) { - Ok(summary) => eprintln!( - "alpha search artifacts written target={} candidates={} rejected={} best={} dir={}", - summary.target, - summary.candidate_count, - summary.rejected_count, - summary.best_candidate.as_deref().unwrap_or(""), - summary.output_dir - ), - Err(err) => { - eprintln!( + if side.is_none() { + match write_alpha_search_artifacts_with_state_and_runtime_feedback( + output_dir, + target_name, + input_names, + &reports, + &autofactor_options, + mcts_state.as_ref(), + runtime_feedback.as_ref(), + llm_prior.as_ref(), + alpha_zoo.as_ref(), + ) { + Ok(summary) => eprintln!( + "alpha search artifacts written target={} candidates={} rejected={} best={} dir={}", + summary.target, + summary.candidate_count, + summary.rejected_count, + summary.best_candidate.as_deref().unwrap_or(""), + summary.output_dir + ), + Err(err) => eprintln!( "alpha search artifact write failed for {}: {err}", - target.as_str() - ); + target_name + ), } } } } Err(err) => { - eprintln!( - "autofactor seed report failed for {}: {err}", - target.as_str() - ); + eprintln!("autofactor seed report failed for {}: {err}", target_name); } } } diff --git a/rust_hft/prediction-markets/crates/ploy-research/src/lib.rs b/rust_hft/prediction-markets/crates/ploy-research/src/lib.rs index e91c6b57a..bdeb5b885 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/src/lib.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/src/lib.rs @@ -109,9 +109,11 @@ pub fn crate_marker() -> &'static str { pub use alpha_search::{ read_mcts_search_state, root_gene, write_alpha_search_artifacts, write_alpha_search_artifacts_with_state, - write_alpha_search_artifacts_with_state_and_runtime_feedback, AlphaSearchArtifactError, - AlphaSearchArtifactSummary, AlphaSearchRuntimeFeedback, AlphaZooEntry, AlphaZooSnapshot, - MctsSearchStateArtifact, MctsSearchStateNode, + write_alpha_search_artifacts_with_state_and_runtime_feedback, + write_side_bound_alpha_search_artifacts_with_state_and_runtime_feedback, + AlphaSearchArtifactError, AlphaSearchArtifactSummary, AlphaSearchRuntimeFeedback, + AlphaZooEntry, AlphaZooSnapshot, MctsSearchStateArtifact, MctsSearchStateNode, + SIDE_BOUND_ALPHA_SEARCH_ARTIFACT_VERSION, }; pub use attribution::{factor_pnl, regime_pnl, AttributionReport, RegimePnl}; pub use autofactor::{