@arong8888/tulip-aibot-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ """Deterministic thinking aids for agents; not hidden reasoning or trade signals.
2
+
3
+ No model/network/filesystem calls, no broker imports and no automatic trading.
4
+ Qualitative tools organize caller-supplied evidence, not certify its truth.
5
+ """
6
+ from copy import deepcopy
7
+ import math
8
+
9
+
10
+ def _text(value, label):
11
+ if not isinstance(value, str) or not value.strip() or len(value) > 2000:
12
+ raise ValueError(f"{label}: expected nonempty text up to 2000 characters")
13
+ return value.strip()
14
+
15
+
16
+ def _number(value, label, low, high):
17
+ if type(value) not in (int, float) or not low <= value <= high:
18
+ raise ValueError(f"{label}: expected finite number in [{low}, {high}]")
19
+ if not math.isfinite(value):
20
+ raise ValueError(f"{label}: number must be finite")
21
+ return float(value)
22
+
23
+
24
+ def _items(value, label, minimum=0, maximum=50):
25
+ if not isinstance(value, list) or not minimum <= len(value) <= maximum:
26
+ raise ValueError(f"{label}: expected list of {minimum}..{maximum} items")
27
+ return value
28
+
29
+
30
+ def _texts(value, label):
31
+ return [_text(v, label) for v in _items(value, label)]
32
+
33
+
34
+ def _record(value, label, required, optional=()):
35
+ if not isinstance(value, dict) or set(value)-set(required)-set(optional) or set(required)-set(value):
36
+ raise ValueError(f"{label}: required keys {required}; optional keys {optional}")
37
+ return value
38
+
39
+
40
+ def first_principles(problem, success_metric, facts, assumptions, constraints=None):
41
+ """Separate observations from premises and make validation gaps explicit."""
42
+ problem, metric = _text(problem, 'problem'), _text(success_metric, 'success_metric')
43
+ constraints = _texts([] if constraints is None else constraints, 'constraints')
44
+ observations = []
45
+ for i, item in enumerate(_items(facts, 'facts')):
46
+ item = _record(item, 'fact', ('claim', 'evidence'))
47
+ observations.append({'id':f'F{i+1}', 'claim':_text(item['claim'], 'claim'),
48
+ 'evidence':_text(item['evidence'], 'evidence'), 'verification':'caller_supplied_not_independently_verified'})
49
+ premises, experiments = [], []
50
+ for i, item in enumerate(_items(assumptions, 'assumptions')):
51
+ item = _record(item, 'assumption', ('claim',), ('test', 'falsifier'))
52
+ test = _text(item['test'], 'test') if 'test' in item else None
53
+ falsifier = _text(item['falsifier'], 'falsifier') if 'falsifier' in item else None
54
+ ident = f'A{i+1}'
55
+ premises.append({'id':ident, 'claim':_text(item['claim'], 'claim'), 'status':'unverified'})
56
+ experiments.append({'assumption_id':ident, 'test':test, 'falsifier':falsifier,
57
+ 'ready_for_review':bool(test and falsifier)})
58
+ gaps = []
59
+ if not observations: gaps.append('No evidence supplied; do not relabel assumptions as facts.')
60
+ if not premises: gaps.append('No assumptions listed; check whether hidden premises were omitted.')
61
+ gaps += [f"{x['assumption_id']}: supply a test and explicit falsification criterion."
62
+ for x in experiments if not x['ready_for_review']]
63
+ return {'mode':'first_principles', 'problem':problem, 'success_metric':metric,
64
+ 'observations':observations, 'assumptions':premises, 'constraints':constraints,
65
+ 'experiments':experiments, 'gaps':gaps,
66
+ 'rebuild_questions':['Which causal mechanism connects the observations to the goal?',
67
+ 'What is the smallest experiment that distinguishes alternative explanations?',
68
+ 'Can the goal be achieved without the untested premise?'],
69
+ 'decision':'analysis_only_no_execution_approval'}
70
+
71
+
72
+ def compound(initial, returns, contribution=0.0, fee_rate=0.0, period_label='period'):
73
+ """End-of-period contribution; fee applies to post-return invested balance.
74
+
75
+ All rates are decimal, not percentages. No tax, borrowing, withdrawals or
76
+ guaranteed returns. A -100% return destroys existing capital; later external
77
+ contributions can recapitalize the account but do not reverse that loss.
78
+ """
79
+ initial = _number(initial, 'initial', 0, 1e12)
80
+ rates = [_number(r, 'return', -1, 10) for r in _items(returns, 'returns', 1, 600)]
81
+ contribution = _number(contribution, 'contribution', 0, 1e12)
82
+ fee_rate = _number(fee_rate, 'fee_rate', 0, 1)
83
+ period_label = _text(period_label, 'period_label')
84
+ balance, no_fee, total_fees = initial, initial, 0.0
85
+ index, peak, max_drawdown = 1.0, 1.0, 0.0
86
+ path = []
87
+ for i, rate in enumerate(rates, 1):
88
+ gross = balance*(1+rate)
89
+ fee = gross*fee_rate
90
+ balance = gross-fee+contribution
91
+ no_fee = no_fee*(1+rate)+contribution
92
+ total_fees += fee
93
+ index *= (1+rate)*(1-fee_rate)
94
+ if not all(math.isfinite(x) and abs(x) <= 1e100 for x in (balance, no_fee, total_fees, index)):
95
+ raise ValueError('Scenario exceeds supported numeric range')
96
+ peak = max(peak, index)
97
+ drawdown = 1-index/peak
98
+ max_drawdown = max(max_drawdown, drawdown)
99
+ path.append({'period':i, 'return':rate, 'fee':fee, 'contribution':contribution, 'balance':balance})
100
+ paid_in = initial+len(rates)*contribution
101
+ geometric = math.expm1(sum(math.log1p(r)+math.log1p(-fee_rate) for r in rates)/len(rates)) if all(r>-1 for r in rates) and fee_rate<1 else -1.0
102
+ return {'mode':'compound', 'period_label':period_label, 'periods':len(rates),
103
+ 'final_balance':balance, 'total_contributed':paid_in, 'net_growth':balance-paid_in,
104
+ 'fees_paid':total_fees, 'no_fee_final_balance':no_fee, 'fee_drag':no_fee-balance,
105
+ 'arithmetic_mean_gross_return':sum(rates)/len(rates), 'geometric_mean_net_return':geometric,
106
+ 'flow_adjusted_max_drawdown':max_drawdown,
107
+ 'recovery_gain_from_max_drawdown':None if max_drawdown>=1 else max_drawdown/(1-max_drawdown),
108
+ 'path':path, 'assumptions':['Contribution is added after return and fees each period.',
109
+ 'Drawdown uses a unit capital index excluding external contributions; zero index stays zero.',
110
+ 'Scenarios are supplied by the caller; this is not a forecast or expected return.',
111
+ 'No tax, inflation, margin, execution slippage or annualization.']}
112
+
113
+
114
+ def bayesian(prior, evidence, assume_conditional_independence=False):
115
+ """Binary H / not-H Bayesian update in log space to avoid underflow."""
116
+ prior = _number(prior, 'prior', 0, 1)
117
+ evidence = _items(evidence, 'evidence', 1, 50)
118
+ if type(assume_conditional_independence) is not bool:
119
+ raise ValueError('assume_conditional_independence must be boolean')
120
+ if len(evidence)>1 and not assume_conditional_independence:
121
+ raise ValueError('Multiple evidence requires explicit conditional independence assumption; combine correlated evidence outside this tool')
122
+ log_h = math.log(prior) if prior else -math.inf
123
+ log_not = math.log1p(-prior) if prior<1 else -math.inf
124
+ steps, seen = [], set()
125
+ for item in evidence:
126
+ item = _record(item, 'evidence', ('id', 'p_given_h', 'p_given_not_h'))
127
+ ident = _text(item['id'], 'evidence id')
128
+ if ident in seen: raise ValueError('Duplicate evidence id; do not count the same observation twice')
129
+ seen.add(ident)
130
+ ph = _number(item['p_given_h'], 'p_given_h', 0, 1)
131
+ pn = _number(item['p_given_not_h'], 'p_given_not_h', 0, 1)
132
+ log_h += math.log(ph) if ph else -math.inf
133
+ log_not += math.log(pn) if pn else -math.inf
134
+ top = max(log_h, log_not)
135
+ if top == -math.inf:
136
+ raise ValueError('Evidence has zero probability under the supplied model; posterior undefined')
137
+ weight_h, weight_not = math.exp(log_h-top), math.exp(log_not-top)
138
+ posterior = weight_h/(weight_h+weight_not)
139
+ steps.append({'id':ident, 'p_given_h':ph, 'p_given_not_h':pn, 'posterior':posterior,
140
+ 'log_likelihood_ratio':math.log(ph)-math.log(pn) if ph and pn else None,
141
+ 'zero_likelihood':('h' if ph==0 else 'not_h') if ph==0 or pn==0 else None})
142
+ return {'mode':'bayesian', 'prior':prior, 'posterior':steps[-1]['posterior'], 'steps':steps,
143
+ 'conditional_independence_assumed':len(evidence)>1,
144
+ 'warnings':['Likelihoods and prior are caller estimates, not calibrated by this tool.',
145
+ 'Probability is not trade confidence, position size or permission to execute.',
146
+ 'Correlated evidence can create false certainty; unique IDs do not prove independence.'] +
147
+ (['A prior of 0 or 1 cannot be changed by ordinary evidence.'] if prior in (0,1) else [])}
148
+
149
+
150
+ def inversion(goal, failures):
151
+ """Pre-mortem checklist; severity is an ordinal caller judgment, not odds."""
152
+ goal = _text(goal, 'goal')
153
+ rows = []
154
+ for i, item in enumerate(_items(failures, 'failures', 1)):
155
+ item = _record(item, 'failure', ('outcome', 'cause', 'severity'), ('warning_sign', 'prevention', 'verification', 'stop_condition'))
156
+ if type(item['severity']) is not int or not 1<=item['severity']<=5:
157
+ raise ValueError('severity must be an ordinal integer in 1..5')
158
+ row = {'id':f'R{i+1}', 'outcome':_text(item['outcome'], 'outcome'),
159
+ 'cause':_text(item['cause'], 'cause'), 'severity':item['severity']}
160
+ for key in ('warning_sign', 'prevention', 'verification', 'stop_condition'):
161
+ row[key] = _text(item[key], key) if key in item else None
162
+ row['missing_controls'] = [k for k in ('warning_sign', 'prevention', 'verification', 'stop_condition') if row[k] is None]
163
+ rows.append(row)
164
+ rows.sort(key=lambda r:-r['severity'])
165
+ return {'mode':'inversion', 'goal':goal, 'premortem_question':'Assume this goal failed. What could have caused it?',
166
+ 'failure_register':rows, 'review_first':[r['id'] for r in rows if r['severity']>=4 or r['missing_controls']],
167
+ 'decision':'review_required',
168
+ 'limitations':['Causes and controls are caller-supplied, not an exhaustive risk discovery.',
169
+ 'Severity is ordinal. No risk probability or success score is inferred.',
170
+ 'A completed checklist does not certify safety; test the controls.']}
171
+
172
+
173
+ def _obj(properties, required):
174
+ return {'type':'object', 'properties':properties, 'required':required, 'additionalProperties':False}
175
+
176
+ TEXT = {'type':'string','minLength':1,'maxLength':2000}
177
+ PROB = {'type':'number','minimum':0,'maximum':1}
178
+ def _array(items, minimum=0, maximum=50):
179
+ return {'type':'array','items':items,'minItems':minimum,'maxItems':maximum}
180
+
181
+ DEFINITIONS = [
182
+ {'name':'think_first_principles','description':'第一性原理:分离证据、假设、约束及可证伪实验,不认证事实',
183
+ 'inputSchema':_obj({'problem':TEXT,'success_metric':TEXT,
184
+ 'facts':_array(_obj({'claim':TEXT,'evidence':TEXT},['claim','evidence'])),
185
+ 'assumptions':_array(_obj({'claim':TEXT,'test':TEXT,'falsifier':TEXT},['claim'])),
186
+ 'constraints':_array(TEXT)},['problem','success_metric','facts','assumptions'])},
187
+ {'name':'think_compound','description':'复利思维:逐期收益、费用、期末投入及资金流剔除后的回撤;小数收益率0.01=1%',
188
+ 'inputSchema':_obj({'initial':{'type':'number','minimum':0,'maximum':1e12},
189
+ 'returns':_array({'type':'number','minimum':-1,'maximum':10},1,600),
190
+ 'contribution':{'type':'number','minimum':0,'maximum':1e12,'default':0},
191
+ 'fee_rate':{**PROB,'default':0},'period_label':TEXT},['initial','returns'])},
192
+ {'name':'think_bayesian','description':'贝叶斯:二元假设的先验与显式似然更新;多条证据必须确认条件独立假设',
193
+ 'inputSchema':_obj({'prior':PROB,'evidence':_array(_obj({'id':TEXT,'p_given_h':PROB,'p_given_not_h':PROB},
194
+ ['id','p_given_h','p_given_not_h']),1),
195
+ 'assume_conditional_independence':{'type':'boolean','default':False}},['prior','evidence'])},
196
+ {'name':'think_inversion','description':'逆向思维:失败预演、预警、防护、验证和停止条件;不预测成功概率',
197
+ 'inputSchema':_obj({'goal':TEXT,'failures':_array(_obj({'outcome':TEXT,'cause':TEXT,
198
+ 'severity':{'type':'integer','minimum':1,'maximum':5},'warning_sign':TEXT,'prevention':TEXT,
199
+ 'verification':TEXT,'stop_condition':TEXT},['outcome','cause','severity']),1)},['goal','failures'])}
200
+ ]
201
+ FUNCTIONS = dict(think_first_principles=first_principles, think_compound=compound,
202
+ think_bayesian=bayesian, think_inversion=inversion)
203
+
204
+
205
+ def list_tools():
206
+ return deepcopy(DEFINITIONS)
207
+
208
+
209
+ def has_tool(name):
210
+ return isinstance(name, str) and name in FUNCTIONS
211
+
212
+
213
+ def call(name, args=None):
214
+ try:
215
+ if not has_tool(name): raise ValueError('Unknown thinking tool')
216
+ if args is None: args={}
217
+ if not isinstance(args, dict): raise ValueError('arguments must be an object')
218
+ return {'ok':True, 'result':FUNCTIONS[name](**args)}
219
+ except (ValueError, TypeError, OverflowError) as exc:
220
+ return {'ok':False, 'error':str(exc)}
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """@arong8888/tulip-aibot-mcp 的 stdio MCP 服务器。
3
+
4
+ 只暴露纯离线、纯标准库的计算工具:量化指标/风险/仓位(quant_tools)、
5
+ 代理工具(agent_tools)、思维辅助(reasoning_tools)。
6
+ 不连接 MT5、不读本地文件路径之外的数据、不下单。
7
+ 协议:JSON-RPC 2.0 over stdio(每行一个请求)。
8
+ """
9
+ import json
10
+ import sys
11
+
12
+ import agent_tools
13
+ import quant_tools
14
+ import reasoning_tools
15
+
16
+ REGISTRIES = [agent_tools, quant_tools, reasoning_tools]
17
+ SERVER_INFO = {"name": "tulip-aibot-mcp", "version": "0.1.0"}
18
+
19
+
20
+ def tool_definitions():
21
+ defs = []
22
+ for mod in REGISTRIES:
23
+ defs.extend(mod.list_tools())
24
+ return defs
25
+
26
+
27
+ def dispatch(name, args):
28
+ for mod in REGISTRIES:
29
+ if mod.has_tool(name):
30
+ fn = getattr(mod, "call_tool", None) or getattr(mod, "call")
31
+ return fn(name, args)
32
+ return {"ok": False, "error": f"未知工具: {name}"}
33
+
34
+
35
+ def send(payload):
36
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
37
+ sys.stdout.flush()
38
+
39
+
40
+ def handle(req):
41
+ method = req.get("method", "")
42
+ rid = req.get("id")
43
+ if rid is None:
44
+ return # 通知(如 notifications/initialized):无需应答
45
+ try:
46
+ if method == "initialize":
47
+ result = {"protocolVersion": "2024-11-05",
48
+ "capabilities": {"tools": {"listChanged": False}},
49
+ "serverInfo": SERVER_INFO}
50
+ elif method == "tools/list":
51
+ result = {"tools": tool_definitions()}
52
+ elif method == "tools/call":
53
+ params = req.get("params") or {}
54
+ res = dispatch(params.get("name", ""), params.get("arguments") or {})
55
+ result = {"content": [{"type": "text",
56
+ "text": json.dumps(res, ensure_ascii=False)}]}
57
+ if isinstance(res, dict) and res.get("ok") is False:
58
+ result["isError"] = True
59
+ elif method == "ping":
60
+ result = {}
61
+ else:
62
+ send({"jsonrpc": "2.0", "id": rid,
63
+ "error": {"code": -32601, "message": f"Unknown method: {method}"}})
64
+ return
65
+ send({"jsonrpc": "2.0", "id": rid, "result": result})
66
+ except Exception as exc: # 服务器永不崩溃:错误转 JSON-RPC error
67
+ send({"jsonrpc": "2.0", "id": rid,
68
+ "error": {"code": -32603, "message": f"Internal error: {exc}"}})
69
+
70
+
71
+ def main():
72
+ # Windows 管道默认 GBK 编码,MCP 客户端约定 UTF-8
73
+ sys.stdin.reconfigure(encoding="utf-8")
74
+ sys.stdout.reconfigure(encoding="utf-8")
75
+ for line in sys.stdin:
76
+ line = line.strip()
77
+ if not line:
78
+ continue
79
+ try:
80
+ req = json.loads(line)
81
+ except json.JSONDecodeError:
82
+ continue
83
+ if isinstance(req, dict):
84
+ handle(req)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()