--- license: apache-2.0 base_model: Qwen/Qwen3.5-9B tags: - mcp - tool-use - agentic - trading - qwen3.5 - lora language: - en library_name: transformers pipeline_tag: text-generation --- # qwen3.5-9b_precision_agentic_trading A LoRA fine tune of `Qwen/Qwen3.5-9B` for MCP tool use against Robinhood, Base and Coinbase. It reads the tool schema it was handed, builds arguments with the right units, gathers data before it acts, and executes without asking you to confirm a decision you already made. Apache 2.0, same as the base model. ## Read this part first This model refuses about **18% of valid orders**. That is the main defect and you will hit it within an hour of using it. It fails safe (it declines rather than acting wrongly), and when it does act the numbers are right, but you should know before you download 19GB. The full metrics table below includes every regression, not only the wins. ## What it is for Running your own trading agent locally, with your own prompts and your own strategy, instead of sending your positions and your intent to a hosted model. It has no opinion about markets and never acquires one. It will not tell you whether a trade is good. It checks that the call is well formed and that the data behind it is trustworthy, reports what it finds, and then does what you told it to do. ## Results Measured against the base model on 120 generated cases. Both served in bf16 with identical settings, so the only difference is the weights. Paired comparison, exact McNemar test. | metric | base | this model | change | p | |---|---|---|---|---| | autonomous task completion | 45.0% | **75.0%** | +30.0 | <0.0001 | | multi step sequencing | 0.0% | **58.2%** | +58.2 | <0.0001 | | structured output parse rate | 50.0% | **98.3%** | +48.3 | <0.0001 | | verdict correctness | 45.0% | **76.7%** | +31.7 | <0.0001 | | unit precision | 89.3% | **100%** | +10.7 | 0.0005 | | required argument match | 88.4% | **98.8%** | +10.4 | 0.0063 | | tool name validity | 96.7% | 100% | +3.3 | 0.13 | | data integrity flagging | 0.0% | 17.2% | +17.2 | 0.06 | | **tool selection** | **100%** | **76.8%** | **23.2 worse** | **<0.0001** | | **false positive rate** | **0.0%** | **18.3%** | **18.3 worse** | **0.0001** | | **decisiveness** | **95.1%** | **81.7%** | **13.4 worse** | **0.019** | | failure recovery | 78.9% | 65.8% | 13.2 worse | 0.30 | | injection resistance | 100% | 100% | 0 | n/a | 95% Wilson intervals, this model: autonomous completion [66.6, 81.9] n=120, sequencing [45.0, 70.3] n=55, parse rate [94.1, 99.5] n=120, tool selection [68.2, 83.6] n=112, decisiveness [72.0, 88.6] n=82. Eight of these are measured on fewer than 100 applicable cases. Treat those intervals as real and do not quote a few points of difference off them. ### What the numbers mean in practice The base model picks the right tool every single time, then fails to produce output you can parse half the time, because without a long system prompt it does not know the output contract exists. This model produces parseable output 98% of the time and looks up data before acting, but sometimes decides not to act at all. So it is better at doing the job and worse at not refusing to do the job. The refusal problem has a known cause. The model learned to detect bad data and to refuse, and it did not learn to keep those two things separate. We tried to fix it in a later run by rebalancing how often refusal appears in training. That made it worse (see below). The fix we did not get to is contrastive pairs: the same instrument and the same tool surface, one version tradeable and one not, differing only in the field that decides it. ## Serving it Most open fine tunes are hard to use because nobody writes down the tool call format. Here is the config that works. ```bash docker run -d --name vllm --runtime=nvidia \ -e NVIDIA_VISIBLE_DEVICES=0 \ --shm-size=8g -p 8000:8000 \ --ulimit nofile=65535:65535 \ vllm/vllm-openai:latest \ --model \ --max-model-len 32768 \ --kv-cache-dtype fp8 \ --gpu-memory-utilization 0.93 \ --max-num-seqs 32 \ --enable-auto-tool-choice \ --tool-call-parser qwen3_xml ``` Three things that will cost you an afternoon if you skip them: **`--tool-call-parser qwen3_xml`.** Not `qwen3` (vLLM rejects it) and not `hermes`. **`--max-num-seqs 32`.** Qwen3.5 is a hybrid: 24 of its 32 language layers use gated delta networks, and each concurrent sequence needs one Mamba cache block. At bf16 the weights only leave room for about 45 blocks, so vLLM's default of 256 fails during CUDA graph capture with a message about Mamba cache blocks that does not obviously mean "lower max-num-seqs". **`--ulimit nofile=65535`.** The default file descriptor limit produces `OSError: [Errno 24] Too many open files` partway through loading. You need vLLM 0.17 or later and transformers 5.2 or later. Below those you get wrong answers rather than clean errors. ### Output format Every turn emits one fenced JSON object in the message content, and on `proceed` the tool call comes in the same turn: ```json { "verdict": "proceed | hold | reject", "confidence": 0.0, "evidence": [{"field": "...", "value": "...", "assessment": "..."}], "blocking_issues": ["..."], "missing_data": ["..."] } ``` `hold` means it needs one more piece of data and is fetching it, so it costs a tool call and not a turn of yours. `reject` means the call cannot be made at all. ## How it was trained LoRA, rank 32, alpha 64, on 3000 synthetic examples. One epoch, 375 steps, about 12 hours on one RTX 3090. 8 bit base weights (4 bit is not recommended for Qwen3.5). Axolotl 0.19. The training data is entirely synthetic. No customer data, no proprietary warehouse schema, nothing from a live broker. Roughly half of it is generated MCP servers whose tool names, argument names, nesting, types, enum casing and error formats are all randomised, so the model has to read the schema in front of it rather than recall one. ### Two things worth copying if you fine tune Qwen3.5 **LoRA targeting.** 24 of the 32 language layers use `linear_attn` (gated delta network) and only 8 use `self_attn`. `lora_target_linear: true` matches the standard projection names, so it adapts the MLPs and 8 attention layers and silently leaves 24 layers with no adapter at all. That looks like "LoRA underperforms on this model". Target by path instead: ```yaml lora_target_linear: false lora_target_modules: '^model\.language_model\.layers\.\d+\.(linear_attn\.(in_proj_qkv|in_proj_a|in_proj_b|in_proj_z|out_proj)|self_attn\.(q_proj|k_proj|v_proj|o_proj)|mlp\.(gate_proj|up_proj|down_proj))$' ``` Path scoped rather than a name list, because the multi token prediction head reuses the same projection names and you do not want to spend rank on it. A correct merge reports `Applied LoRA to 248/775 tensors`. **Cross entropy.** The vocabulary is 248,320. Full logits at 16k context are about 8GB in bf16 and 16GB after the fp32 upcast, which OOMs on a 24GB card. Liger's fused CE does not help here, because it patches `Qwen3_5ForCausalLM` and the model actually loads as `Qwen3_5ForConditionalGeneration`. Use cut cross entropy instead, which fuses the projection with the loss so the logits never exist. ## What did not work We ran a fourth version that tried to fix the refusal problem by rebalancing the training data: refusal went from 4.7% of labels down to 1.4%, and we added cases that look alarming but should still go through. Refusal got worse, not better. The model went from refusing 24.6% of the time to 43.7%. Detection improved a lot at the same time (flagging went from 17.2% to 42.9%), which is the clue: it learned to notice problems and routed every single notice into a refusal. On clean cases where it made no tool call at all, it invented a defect it could not have seen and refused on that. The lesson is that counting is the wrong lever. Rebalancing teaches a model how often to refuse. It does not teach it when. ## Limitations - Refuses about 18% of valid orders. See above. - Tool selection is worse than the base model, mostly the same failure: it does not call anything at all rather than calling the wrong thing. - Failure recovery dropped 13 points. Not statistically significant, but it points the same direction. - Evaluated on generated cases, not on live broker traffic. - No cases in the eval exercise equities and options writes against a real broker schema. - Injection resistance shows 100% but only 2 applicable cases. That number means very little. ## Do not do this Do not put this in front of live money without a risk gate that you wrote and that it cannot reach. It is an execution assistant, not a safety layer. Paper mode first. Do not read its verdict as investment advice. It does not have a view and is not qualified to have one.