diff --git a/config/llm_settings.json b/config/llm_settings.json index f15bbb2..8661cb4 100644 --- a/config/llm_settings.json +++ b/config/llm_settings.json @@ -1,9 +1,9 @@ { - "provider": "openrouter", - "model": "minimax/minimax-m2.5", + "provider": "ollama", + "model": "gemma4:12b-mlx", "providers": { "ollama": { - "base_url": "http://100.100.242.21:11434" + "base_url": "http://100.79.255.5:11434" }, "lmstudio": { "base_url": "http://100.100.242.21:1234" diff --git a/data/score_history.jsonl b/data/score_history.jsonl index 97b65a1..e278f4c 100644 --- a/data/score_history.jsonl +++ b/data/score_history.jsonl @@ -110,3 +110,40 @@ {"timestamp": "2026-03-21T22:54:22.144542+00:00", "composite_score": 70.0, "scored_count": 9, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": null, "value": null}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-21T22:55:08.385540+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26554568527919}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-21T22:55:33.933753+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26316624365482}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T18:10:22.517545+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.07407994923858}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T18:25:30.189079+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.098667512690355}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T18:26:00.556392+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.098667512690355}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T18:40:30.805472+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.04473350253808}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T18:55:31.544772+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.066148477157356}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T18:57:42.639217+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.04790609137056}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T19:12:44.307793+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.02014593908629}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T19:27:45.019177+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.22874365482234}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T19:42:45.679921+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.35009517766498}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T19:57:46.474768+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.34850888324873}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T20:12:47.228456+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.2604695431472}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T20:27:47.904578+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.27633248730964}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T20:42:48.631663+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.26919416243655}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T20:57:49.365132+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.264435279187815}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T21:12:50.093194+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.202569796954315}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T21:27:50.802146+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.16132614213198}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T21:42:51.460898+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.24857233502538}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T21:57:52.223283+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.318369289340104}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T22:12:52.898551+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.37071700507614}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T22:27:54.711182+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.429409898477154}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-27T22:42:55.510257+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.31043781725888}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-28T20:31:14.232026+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.70066624365482}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-28T20:35:38.591732+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.73080583756345}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-28T20:42:21.081121+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.721288071065985}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-28T20:57:21.749415+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.75301395939086}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}} +{"timestamp": "2026-06-28T21:09:38.418891+00:00", "composite_score": 68.1, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.73159898477158}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 4, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T21:11:19.117377+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.723667512690355}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T21:26:19.843356+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.723667512690355}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T21:39:03.697500+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.68797588832488}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T21:42:37.143098+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.71890862944163}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T21:57:37.803751+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.96002538071066}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T22:12:38.557670+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.72049492385786}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T22:27:39.293792+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.94733502538072}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T22:42:40.175733+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 53.05678934010152}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T22:57:40.874889+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 53.22255710659899}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T23:12:41.665346+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 53.02744289340101}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} +{"timestamp": "2026-06-28T23:27:42.349311+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.95923223350254}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}} diff --git a/ml/optimizer.py b/ml/optimizer.py index d20c06a..7fea652 100644 --- a/ml/optimizer.py +++ b/ml/optimizer.py @@ -43,6 +43,9 @@ START_DATE = "2018-02-01" TRAIN_CUTOFF_DAYS = 365 # Target: forward 365d return > 30% = "good time to buy" GOOD_BUY_THRESHOLD = 30.0 +# Validation embargo/purge horizon: labels use 365-day forward returns. +LABEL_HORIZON_DAYS = 365 +VALIDATION_SPLITS = 5 # The 8 core metrics we score METRIC_KEYS = [ @@ -95,6 +98,112 @@ def score_range(value, ranges): return 0 +SCORE_KEYS = [ + "puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio", + "nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp", +] + +SCORE_FEATURES = [f"score_{k}" for k in SCORE_KEYS] +RAW_FEATURES = [ + "raw_puell_multiple", "raw_mvrv_zscore", "raw_reserve_risk", + "raw_rhodl_ratio", "raw_nupl", "raw_fear_greed", + "raw_pct_above_200w_sma", "raw_pct_above_lth_rp", "raw_drawdown", +] +DELTA_FEATURES = [ + "delta_30d_mvrv_zscore", "delta_30d_nupl", + "delta_30d_puell_multiple", "delta_30d_reserve_risk", +] +INTERACTION_FEATURES = ["mvrv_x_nupl", "puell_x_reserve"] +CYCLE_FEATURES = ["days_since_ath"] +FEATURE_COLS = SCORE_FEATURES + RAW_FEATURES + DELTA_FEATURES + INTERACTION_FEATURES + CYCLE_FEATURES + +BRACKETS = [ + (0, 20, "Extreme Caution"), + (21, 40, "Caution"), + (41, 55, "Neutral"), + (56, 70, "Moderate Opportunity"), + (71, 85, "Strong Accumulation"), + (86, 100, "Extreme Accumulation"), +] + + +def _row_date(row): + return datetime.strptime(row["date"], "%Y-%m-%d") + + +def purged_time_series_splits(rows, n_splits=VALIDATION_SPLITS, + label_horizon_days=LABEL_HORIZON_DAYS, + embargo_days=0): + """Yield expanding-window splits with overlapping forward-label windows removed. + + A row dated T with a 365-day forward-return label consumes information up to + T+365. For validation beginning at V, any training row whose label window + reaches V is removed. This keeps validation metrics out-of-sample for the + forward-return label, not just for features. + """ + base_splitter = TimeSeriesSplit(n_splits=n_splits) + row_dates = [_row_date(r) for r in rows] + horizon = timedelta(days=label_horizon_days) + embargo = timedelta(days=embargo_days) + + for train_idx, val_idx in base_splitter.split(np.arange(len(rows))): + val_start = row_dates[val_idx[0]] + val_end = row_dates[val_idx[-1]] + purged_train = [] + for idx in train_idx: + label_end = row_dates[idx] + horizon + before_validation_label_window = label_end <= val_start - embargo + after_validation_embargo = row_dates[idx] > val_end + embargo + if before_validation_label_window or after_validation_embargo: + purged_train.append(idx) + if purged_train: + yield np.array(purged_train, dtype=int), np.array(val_idx, dtype=int) + + +def _build_model(): + return GradientBoostingClassifier( + n_estimators=300, + learning_rate=0.05, + max_depth=4, + subsample=0.8, + min_samples_leaf=20, + random_state=42, + ) + + +def derive_metric_weights(feature_cols, importances): + """Aggregate feature importances back to transparent score metric weights.""" + metric_names = list(SCORE_KEYS) + feature_to_metric = {} + for m in metric_names: + feature_to_metric[f"score_{m}"] = m + feature_to_metric[f"raw_{m}"] = m + feature_to_metric["delta_30d_mvrv_zscore"] = "mvrv_zscore" + feature_to_metric["delta_30d_nupl"] = "nupl" + feature_to_metric["delta_30d_puell_multiple"] = "puell_multiple" + feature_to_metric["delta_30d_reserve_risk"] = "reserve_risk" + + metric_importances = {m: 0.0 for m in metric_names} + for name, imp in zip(feature_cols, importances): + if name in feature_to_metric: + metric_importances[feature_to_metric[name]] += float(imp) + elif name == "mvrv_x_nupl": + metric_importances["mvrv_zscore"] += float(imp) / 2 + metric_importances["nupl"] += float(imp) / 2 + elif name == "puell_x_reserve": + metric_importances["puell_multiple"] += float(imp) / 2 + metric_importances["reserve_risk"] += float(imp) / 2 + elif name == "days_since_ath": + metric_importances["drawdown"] += float(imp) + + total_imp = sum(metric_importances.values()) + if total_imp > 0: + weights = {k: round(v / total_imp, 4) for k, v in metric_importances.items()} + else: + weights = {k: round(1 / len(metric_importances), 4) for k in metric_importances} + return dict(sorted(weights.items(), key=lambda x: x[1], reverse=True)) + + def build_dataset(index, thresholds): """Build aligned training dataset: metric scores + forward returns.""" # Get all dates from 2018-02-01 onward @@ -257,39 +366,33 @@ def train_model(rows): log.info("Target distribution: %d positive (%.1f%%), %d negative", positive, positive / len(labeled) * 100, len(labeled) - positive) - # Feature columns: scores + raw values + deltas + interactions + cycle position - score_features = [ - "score_puell_multiple", "score_mvrv_zscore", "score_reserve_risk", - "score_rhodl_ratio", "score_nupl", "score_fear_greed", - "score_drawdown", "score_pct_above_200w_sma", "score_pct_above_lth_rp", - ] - raw_features = [ - "raw_puell_multiple", "raw_mvrv_zscore", "raw_reserve_risk", - "raw_rhodl_ratio", "raw_nupl", "raw_fear_greed", - "raw_pct_above_200w_sma", "raw_pct_above_lth_rp", "raw_drawdown", - ] - delta_features = [ - "delta_30d_mvrv_zscore", "delta_30d_nupl", - "delta_30d_puell_multiple", "delta_30d_reserve_risk", - ] - interaction_features = ["mvrv_x_nupl", "puell_x_reserve"] - cycle_features = ["days_since_ath"] - - feature_cols = score_features + raw_features + delta_features + interaction_features + cycle_features + feature_cols = FEATURE_COLS X = np.array([[r[f] for f in feature_cols] for r in labeled]) y = np.array([r["target"] for r in labeled]) log.info("Feature matrix: %d samples x %d features", X.shape[0], X.shape[1]) - # Time-series cross-validation (expanding window, 5 splits) - tscv = TimeSeriesSplit(n_splits=5) + # Purged time-series cross-validation. Standard TimeSeriesSplit is not + # enough here because each label consumes the next 365 days of returns. cv_scores = [] cv_f1 = [] cv_precision = [] cv_recall = [] + fold_results = [] - for fold, (train_idx, val_idx) in enumerate(tscv.split(X)): + splits = list(purged_time_series_splits( + labeled, + n_splits=VALIDATION_SPLITS, + label_horizon_days=LABEL_HORIZON_DAYS, + embargo_days=0, + )) + if not splits: + log.error("No viable purged validation splits. Need more history for %dd label horizon.", + LABEL_HORIZON_DAYS) + return None + + for fold, (train_idx, val_idx) in enumerate(splits): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] @@ -297,14 +400,7 @@ def train_model(rows): X_train_s = scaler.fit_transform(X_train) X_val_s = scaler.transform(X_val) - model = GradientBoostingClassifier( - n_estimators=300, - learning_rate=0.05, - max_depth=4, - subsample=0.8, - min_samples_leaf=20, - random_state=42, - ) + model = _build_model() model.fit(X_train_s, y_train) y_pred = model.predict(X_val_s) @@ -320,27 +416,40 @@ def train_model(rows): cv_precision.append(prec) cv_recall.append(rec) - train_dates = f"{labeled[train_idx[0]]['date']} to {labeled[train_idx[-1]]['date']}" - val_dates = f"{labeled[val_idx[0]]['date']} to {labeled[val_idx[-1]]['date']}" + fold_weights = derive_metric_weights(feature_cols, model.feature_importances_) + fold_results.append({ + "fold": fold + 1, + "train_idx": train_idx.tolist(), + "val_idx": val_idx.tolist(), + "weights": fold_weights, + "metrics": { + "auc": round(float(auc), 4), + "f1": round(float(f1), 4), + "precision": round(float(prec), 4), + "recall": round(float(rec), 4), + }, + "date_ranges": { + "train": f"{labeled[train_idx[0]]['date']} to {labeled[train_idx[-1]]['date']}", + "validation": f"{labeled[val_idx[0]]['date']} to {labeled[val_idx[-1]]['date']}", + }, + "n_train": len(train_idx), + "n_validation": len(val_idx), + }) + + train_dates = fold_results[-1]["date_ranges"]["train"] + val_dates = fold_results[-1]["date_ranges"]["validation"] log.info("Fold %d: Train %s | Val %s | AUC=%.3f F1=%.3f P=%.3f R=%.3f", fold + 1, train_dates, val_dates, auc, f1, prec, rec) - log.info("CV Mean AUC: %.3f (+/- %.3f)", np.mean(cv_scores), np.std(cv_scores)) - log.info("CV Mean F1: %.3f (+/- %.3f)", np.mean(cv_f1), np.std(cv_f1)) + log.info("Purged CV Mean AUC: %.3f (+/- %.3f)", np.mean(cv_scores), np.std(cv_scores)) + log.info("Purged CV Mean F1: %.3f (+/- %.3f)", np.mean(cv_f1), np.std(cv_f1)) # Train final model on all labeled data log.info("Training final model on all %d labeled samples...", len(labeled)) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) - final_model = GradientBoostingClassifier( - n_estimators=300, - learning_rate=0.05, - max_depth=4, - subsample=0.8, - min_samples_leaf=20, - random_state=42, - ) + final_model = _build_model() final_model.fit(X_scaled, y) # Feature importances @@ -357,48 +466,7 @@ def train_model(rows): bar = "#" * int(imp * 200) log.info(" %-30s %.4f %s", name, imp, bar) - # Extract optimal weights by aggregating importance per metric - # Map each feature back to its parent metric - metric_names = [ - "puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio", - "nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp", - ] - feature_to_metric = {} - for m in metric_names: - feature_to_metric[f"score_{m}"] = m - feature_to_metric[f"raw_{m}"] = m - # Delta features map to their base metric - feature_to_metric["delta_30d_mvrv_zscore"] = "mvrv_zscore" - feature_to_metric["delta_30d_nupl"] = "nupl" - feature_to_metric["delta_30d_puell_multiple"] = "puell_multiple" - feature_to_metric["delta_30d_reserve_risk"] = "reserve_risk" - # Interaction terms split evenly between constituent metrics - # mvrv_x_nupl -> mvrv_zscore + nupl - # puell_x_reserve -> puell_multiple + reserve_risk - - metric_importances = {m: 0.0 for m in metric_names} - for name, imp in feat_imp: - if name in feature_to_metric: - metric_importances[feature_to_metric[name]] += imp - elif name == "mvrv_x_nupl": - metric_importances["mvrv_zscore"] += imp / 2 - metric_importances["nupl"] += imp / 2 - elif name == "puell_x_reserve": - metric_importances["puell_multiple"] += imp / 2 - metric_importances["reserve_risk"] += imp / 2 - # days_since_ath maps to drawdown conceptually - elif name == "days_since_ath": - metric_importances["drawdown"] += imp - - # Normalize weights to sum to 1 - total_imp = sum(metric_importances.values()) - if total_imp > 0: - weights = {k: round(v / total_imp, 4) for k, v in metric_importances.items()} - else: - weights = {k: round(1 / len(metric_importances), 4) for k in metric_importances} - - # Sort by weight descending - weights = dict(sorted(weights.items(), key=lambda x: x[1], reverse=True)) + weights = derive_metric_weights(feature_cols, importances) log.info("\nOptimal Metric Weights:") log.info("-" * 50) @@ -413,6 +481,7 @@ def train_model(rows): log.info("COMPARISON BACKTEST: ML-Weighted vs Equal-Weight") log.info("=" * 60) comparison = run_comparison(rows, weights) + out_of_sample_comparison = run_out_of_sample_comparison(labeled, fold_results) # Build output result = { @@ -424,6 +493,9 @@ def train_model(rows): "mean_f1": round(float(np.mean(cv_f1)), 4), "mean_precision": round(float(np.mean(cv_precision)), 4), "mean_recall": round(float(np.mean(cv_recall)), 4), + "validation_method": "purged_expanding_window", + "label_horizon_days": LABEL_HORIZON_DAYS, + "folds": fold_results, }, "training_info": { "n_samples": len(labeled), @@ -435,66 +507,47 @@ def train_model(rows): "model": "GradientBoostingClassifier", }, "comparison": comparison, + "out_of_sample_comparison": out_of_sample_comparison, "trained_at": datetime.now(tz=__import__('datetime').timezone.utc).isoformat(), } return result -def run_comparison(rows, ml_weights): - """Compare ML-weighted scoring vs equal-weight scoring across score brackets.""" - # Metrics used in scoring (maps to score_* columns) - score_keys = [ - "puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio", - "nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp", - ] - n_metrics = len(score_keys) - equal_weight = 1.0 / n_metrics +def _composite_score(row, mode, ml_weights=None): + scores = [row[f"score_{k}"] for k in SCORE_KEYS] + if mode == "equal_weight" or not ml_weights: + return sum(scores) / len(SCORE_KEYS) * 10 + equal_weight = 1.0 / len(SCORE_KEYS) + weighted_sum = sum(row[f"score_{k}"] * ml_weights.get(k, equal_weight) for k in SCORE_KEYS) + return weighted_sum * 10 - brackets = [ - (0, 20, "Extreme Caution"), - (21, 40, "Caution"), - (41, 55, "Neutral"), - (56, 70, "Moderate Opportunity"), - (71, 85, "Strong Accumulation"), - (86, 100, "Extreme Accumulation"), - ] - # Only use rows with forward returns - scored_rows = [r for r in rows if "fwd_365d" in r] - - results = {"equal_weight": [], "ml_weighted": []} - - for mode in ["equal_weight", "ml_weighted"]: - for r in scored_rows: - scores = [r[f"score_{k}"] for k in score_keys] - if mode == "equal_weight": - composite = sum(scores) / n_metrics * 10 - else: - weighted_sum = sum(r[f"score_{k}"] * ml_weights.get(k, equal_weight) for k in score_keys) - composite = weighted_sum * 10 - r[f"composite_{mode}"] = composite - - for low, high, label in brackets: - days_in = [r for r in scored_rows if low <= r[f"composite_{mode}"] <= high] - if not days_in: - results[mode].append({ - "range": f"{low}-{high}", "label": label, - "days": 0, "avg_365d": None, - }) - continue - returns_365 = [r["fwd_365d"] for r in days_in] - win_rate = len([r for r in returns_365 if r > 0]) / len(returns_365) * 100 - results[mode].append({ - "range": f"{low}-{high}", - "label": label, - "days": len(days_in), - "avg_365d": round(sum(returns_365) / len(returns_365), 2), - "median_365d": round(sorted(returns_365)[len(returns_365) // 2], 2), - "win_rate_365d": round(win_rate, 1), +def _summarize_brackets(scored_rows, score_key): + results = [] + for low, high, label in BRACKETS: + days_in = [r for r in scored_rows if low <= r[score_key] <= high] + if not days_in: + results.append({ + "range": f"{low}-{high}", "label": label, + "days": 0, "avg_365d": None, }) + continue + returns_365 = [r["fwd_365d"] for r in days_in] + returns_sorted = sorted(returns_365) + win_rate = len([r for r in returns_365 if r > 0]) / len(returns_365) * 100 + results.append({ + "range": f"{low}-{high}", + "label": label, + "days": len(days_in), + "avg_365d": round(sum(returns_365) / len(returns_365), 2), + "median_365d": round(returns_sorted[len(returns_sorted) // 2], 2), + "win_rate_365d": round(win_rate, 1), + }) + return results - # Print comparison + +def _log_comparison_table(results): log.info("\n%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s", "Bracket", "EQ Avg", "EQ Med", "EQ Win%", "ML Avg", "ML Med", "ML Win%") log.info("-" * 80) @@ -508,6 +561,47 @@ def run_comparison(rows, ml_weights): log.info("%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s", eq["label"], eq_avg, eq_med, eq_win, ml_avg, ml_med, ml_win) + +def run_comparison(rows, ml_weights): + """Compare final ML-weighted scoring vs equal-weight scoring across all labeled rows. + + This is retained for backwards compatibility with existing output. It is an + in-sample/full-history comparison; prefer out_of_sample_comparison for model + selection decisions. + """ + scored_rows = [dict(r) for r in rows if "fwd_365d" in r] + for r in scored_rows: + r["composite_equal_weight"] = _composite_score(r, "equal_weight") + r["composite_ml_weighted"] = _composite_score(r, "ml_weighted", ml_weights) + + results = { + "equal_weight": _summarize_brackets(scored_rows, "composite_equal_weight"), + "ml_weighted": _summarize_brackets(scored_rows, "composite_ml_weighted"), + } + _log_comparison_table(results) + return results + + +def run_out_of_sample_comparison(rows, fold_results): + """Compare fold-specific ML weights on validation rows only.""" + validation_rows = [] + for fold in fold_results: + weights = fold.get("weights", {}) + for idx in fold.get("val_idx", []): + if idx >= len(rows) or "fwd_365d" not in rows[idx]: + continue + r = dict(rows[idx]) + r["fold"] = fold.get("fold") + r["composite_equal_weight"] = _composite_score(r, "equal_weight") + r["composite_ml_weighted"] = _composite_score(r, "ml_weighted", weights) + validation_rows.append(r) + + results = { + "folds": len(fold_results), + "validation_days": len(validation_rows), + "equal_weight": _summarize_brackets(validation_rows, "composite_equal_weight"), + "ml_weighted": _summarize_brackets(validation_rows, "composite_ml_weighted"), + } return results diff --git a/tests/test_ml_optimizer_validation.py b/tests/test_ml_optimizer_validation.py new file mode 100644 index 0000000..365f01f --- /dev/null +++ b/tests/test_ml_optimizer_validation.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta + +from ml import optimizer + + +def _row(date, returns=10.0, score_200w=10, score_drawdown=0): + row = { + "date": date, + "price": 100.0, + "fwd_365d": returns, + "score_puell_multiple": 0, + "score_mvrv_zscore": 0, + "score_reserve_risk": 0, + "score_rhodl_ratio": 0, + "score_nupl": 0, + "score_fear_greed": 0, + "score_drawdown": score_drawdown, + "score_pct_above_200w_sma": score_200w, + "score_pct_above_lth_rp": 0, + } + return row + + +def test_purged_time_series_splits_remove_overlapping_forward_label_windows(): + start = datetime(2020, 1, 1) + rows = [_row((start + timedelta(days=i)).strftime("%Y-%m-%d")) for i in range(900)] + + splits = list( + optimizer.purged_time_series_splits( + rows, + n_splits=3, + label_horizon_days=365, + embargo_days=0, + ) + ) + + assert splits, "expected at least one viable split" + for train_idx, val_idx in splits: + val_start = datetime.strptime(rows[val_idx[0]]["date"], "%Y-%m-%d") + latest_allowed_train_date = val_start - timedelta(days=365) + assert len(train_idx) > 0, "purging should keep non-overlapping expanding-window training rows" + for idx in train_idx: + train_date = datetime.strptime(rows[idx]["date"], "%Y-%m-%d") + assert train_date <= latest_allowed_train_date + + +def test_run_out_of_sample_comparison_scores_only_validation_rows_with_fold_weights(): + rows = [ + _row("2020-01-01", returns=-10, score_200w=0, score_drawdown=10), + _row("2020-01-02", returns=-5, score_200w=0, score_drawdown=10), + _row("2020-01-03", returns=100, score_200w=10, score_drawdown=10), + _row("2020-01-04", returns=120, score_200w=10, score_drawdown=10), + ] + fold_results = [ + { + "fold": 1, + "val_idx": [2, 3], + "weights": {"pct_above_200w_sma": 1.0, "drawdown": 0.0}, + } + ] + + comparison = optimizer.run_out_of_sample_comparison(rows, fold_results) + + assert comparison["validation_days"] == 2 + assert comparison["folds"] == 1 + assert sum(bucket["days"] for bucket in comparison["ml_weighted"]) == 2 + assert sum(bucket["days"] for bucket in comparison["equal_weight"]) == 2 + + extreme_ml = next(bucket for bucket in comparison["ml_weighted"] if bucket["label"] == "Extreme Accumulation") + assert extreme_ml["days"] == 2 + assert extreme_ml["avg_365d"] == 110.0 + + caution_equal = next(bucket for bucket in comparison["equal_weight"] if bucket["label"] == "Caution") + assert caution_equal["days"] == 2 diff --git a/tests/test_scoring_engine_ml.py b/tests/test_scoring_engine_ml.py new file mode 100644 index 0000000..ffb4ef5 --- /dev/null +++ b/tests/test_scoring_engine_ml.py @@ -0,0 +1,63 @@ +import math + +from scoring import engine + + +def _complete_metrics(): + return { + "fear_greed": {"value": 10, "classification": "Extreme Fear"}, + "puell_multiple": {"value": 0.3}, + "mvrv_zscore": {"value": -0.1}, + "drawdown": {"value": 60.0, "ath": 250.0}, + "price": {"price": 100.0}, + "200w_sma": {"value": 120.0}, + "reserve_risk": {"value": 0.001}, + "rhodl_ratio": {"value": 50.0}, + "nupl": {"value": -0.1}, + "lth_realized_price": {"value": 120.0}, + "hash_ribbons": {"buy_signal": False}, + } + + +def test_score_all_ml_normalizes_displayed_weights_and_contributions(monkeypatch): + monkeypatch.setattr( + engine, + "load_ml_weights", + lambda: { + "fear_greed": 0.40, + "puell_multiple": 0.20, + "mvrv_zscore": 0.15, + "drawdown": 0.10, + "pct_above_200w_sma": 0.05, + "reserve_risk": 0.04, + "rhodl_ratio": 0.03, + "nupl": 0.02, + "pct_above_lth_rp": 0.01, + }, + ) + + scored = engine.score_all_ml(_complete_metrics()) + + assert scored["ml_mode"] is True + valid_metrics = [m for m in scored["metrics"] if m["score"] is not None] + assert scored["ml_weight_total"] == 1.01 # trained weights + small hash-ribbons fallback + assert math.isclose(sum(m["ml_weight"] for m in valid_metrics), 1.0, abs_tol=0.001) + assert math.isclose( + sum(m["ml_contribution"] for m in valid_metrics), + scored["composite_score"], + abs_tol=0.05, + ) + + hash_ribbons = next(m for m in valid_metrics if m["key"] == "hash_ribbons") + assert hash_ribbons["ml_raw_weight"] == 0.01 + assert hash_ribbons["ml_weight"] == round(0.01 / 1.01, 4) + + +def test_score_all_ml_preserves_classic_fallback_when_weights_missing(monkeypatch): + monkeypatch.setattr(engine, "load_ml_weights", lambda: {}) + + scored = engine.score_all_ml(_complete_metrics()) + + assert scored["ml_mode"] is False + assert scored["ml_error"] == "ML weights not found — run ml/optimizer.py" + assert "classic_score" not in scored