@coinrithm/mcp-trading 0.7.7 → 0.7.8
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.
- package/CHANGELOG.md +83 -0
- package/dist/agent/act.js +19 -6
- package/dist/agent/capitalSizing.d.ts +32 -0
- package/dist/agent/capitalSizing.js +257 -0
- package/dist/agent/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -0
- package/dist/agent/decisionReceipt.d.ts +45 -0
- package/dist/agent/decisionReceipt.js +595 -0
- package/dist/agent/decisionValidator.d.ts +19 -2
- package/dist/agent/decisionValidator.js +74 -3
- package/dist/agent/engine.d.ts +1 -0
- package/dist/agent/engine.js +1 -0
- package/dist/agent/observe.js +175 -34
- package/dist/agent/pmContext.d.ts +13 -0
- package/dist/agent/pmContext.js +136 -0
- package/dist/agent/prompt.d.ts +11 -1
- package/dist/agent/prompt.js +135 -7
- package/dist/agent/providerCapabilities.d.ts +3 -0
- package/dist/agent/providerCapabilities.js +41 -3
- package/dist/agent/providers.js +119 -73
- package/dist/agent/resolve.js +1 -0
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +360 -34
- package/dist/agent/scorecard.js +7 -1
- package/dist/agent/skill.js +17 -0
- package/dist/agent/skillValidator.d.ts +1 -0
- package/dist/agent/skillValidator.js +56 -0
- package/dist/agent/state.js +6 -0
- package/dist/agent/strictLint.js +19 -0
- package/dist/agent/thesis.d.ts +40 -0
- package/dist/agent/thesis.js +319 -0
- package/dist/agent/types.d.ts +127 -0
- package/package.json +1 -1
package/dist/agent/scorecard.js
CHANGED
|
@@ -54,8 +54,14 @@ function normalCdf(z) {
|
|
|
54
54
|
return z >= 0 ? 1 - p : p;
|
|
55
55
|
}
|
|
56
56
|
// Max peak-to-trough drawdown (mUSD, >= 0) on a cumulative series.
|
|
57
|
+
//
|
|
58
|
+
// The peak seeds at 0, the curve's implicit origin (cumulative realized PnL
|
|
59
|
+
// starts at zero before the first trade). Seeding at -Infinity made the first
|
|
60
|
+
// point its own peak, so a record opening with losses under-reported: [-500,
|
|
61
|
+
// +500, -300] gave 300 instead of 500 and a monotone-losing record gave 0
|
|
62
|
+
// (2026-09-01 rigor review).
|
|
57
63
|
function maxDrawdown(cumulative) {
|
|
58
|
-
let peak =
|
|
64
|
+
let peak = 0;
|
|
59
65
|
let maxDd = 0;
|
|
60
66
|
for (const c of cumulative) {
|
|
61
67
|
if (!Number.isFinite(c))
|
package/dist/agent/skill.js
CHANGED
|
@@ -63,6 +63,20 @@ function buildModel(raw) {
|
|
|
63
63
|
baseUrl: typeof m.baseUrl === "string" ? m.baseUrl : undefined,
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
+
// No implicit opt-in and no invented risk percentages. Invalid supplied values
|
|
67
|
+
// remain visibly invalid until validateSkill rejects the raw configuration.
|
|
68
|
+
function buildCapitalSizing(raw) {
|
|
69
|
+
const policy = obj(raw);
|
|
70
|
+
return {
|
|
71
|
+
version: policy.version,
|
|
72
|
+
futuresRiskPct: num(policy.futuresRiskPct, Number.NaN),
|
|
73
|
+
pmMaxLossPct: num(policy.pmMaxLossPct, Number.NaN),
|
|
74
|
+
perTicketCapitalPct: num(policy.perTicketCapitalPct, Number.NaN),
|
|
75
|
+
totalCapitalPct: num(policy.totalCapitalPct, Number.NaN),
|
|
76
|
+
cashReservePct: num(policy.cashReservePct, Number.NaN),
|
|
77
|
+
minRewardRisk: num(policy.minRewardRisk, Number.NaN),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
66
80
|
// Coerce raw frontmatter into a best-effort AgentSpec. This NEVER throws on bad
|
|
67
81
|
// values — it fills in what it can and lets validateSkill report problems
|
|
68
82
|
// against the raw frontmatter. The runner only proceeds when validation passes.
|
|
@@ -85,6 +99,9 @@ export function buildSpec(raw) {
|
|
|
85
99
|
},
|
|
86
100
|
model: buildModel(raw.model),
|
|
87
101
|
venues,
|
|
102
|
+
...(raw.capitalSizing !== undefined
|
|
103
|
+
? { capitalSizing: buildCapitalSizing(raw.capitalSizing) }
|
|
104
|
+
: {}),
|
|
88
105
|
risk: {
|
|
89
106
|
maxLeverage: num(risk.maxLeverage, 1),
|
|
90
107
|
perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
|
|
@@ -4,4 +4,5 @@ export interface SkillValidation {
|
|
|
4
4
|
valid: boolean;
|
|
5
5
|
issues: ValidationResult[];
|
|
6
6
|
}
|
|
7
|
+
export declare function validateCapitalSizingPolicy(value: unknown): ValidationResult[];
|
|
7
8
|
export declare function validateSkill(parsed: ParsedSkill, mode?: SkillValidationMode): SkillValidation;
|
|
@@ -4,6 +4,58 @@ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
4
4
|
const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
5
5
|
// maxTradesPerDay accepts 0 as the explicit "unlimited daily trades" sentinel.
|
|
6
6
|
const isNonNegNum = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
7
|
+
// Shared by skill loading and the runtime: persisted specs can bypass the
|
|
8
|
+
// loader, so a present malformed policy must never become permissive NaN math.
|
|
9
|
+
export function validateCapitalSizingPolicy(value) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
const add = (code, reason) => issues.push(fail(code, reason));
|
|
12
|
+
const p = value;
|
|
13
|
+
if (!isObj(p)) {
|
|
14
|
+
add("skill_capital_sizing", "capitalSizing must be an object");
|
|
15
|
+
return issues;
|
|
16
|
+
}
|
|
17
|
+
const percentKeys = [
|
|
18
|
+
"futuresRiskPct",
|
|
19
|
+
"pmMaxLossPct",
|
|
20
|
+
"perTicketCapitalPct",
|
|
21
|
+
"totalCapitalPct",
|
|
22
|
+
];
|
|
23
|
+
const allowedKeys = new Set([
|
|
24
|
+
"version",
|
|
25
|
+
...percentKeys,
|
|
26
|
+
"cashReservePct",
|
|
27
|
+
"minRewardRisk",
|
|
28
|
+
]);
|
|
29
|
+
for (const key of Object.keys(p)) {
|
|
30
|
+
if (!allowedKeys.has(key))
|
|
31
|
+
add("skill_capital_sizing_unknown_key", `unknown capitalSizing key "${key}"`);
|
|
32
|
+
}
|
|
33
|
+
if (p.version !== "equity_fraction_v1") {
|
|
34
|
+
add("skill_capital_sizing_version", 'capitalSizing.version must be "equity_fraction_v1"');
|
|
35
|
+
}
|
|
36
|
+
for (const key of percentKeys) {
|
|
37
|
+
if (!isPosNum(p[key]) || p[key] > 100) {
|
|
38
|
+
add("skill_capital_sizing_percent", `capitalSizing.${key} must be a finite number in (0, 100]`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!isNonNegNum(p.cashReservePct) || p.cashReservePct >= 100) {
|
|
42
|
+
add("skill_capital_sizing_reserve", "capitalSizing.cashReservePct must be a finite number in [0, 100)");
|
|
43
|
+
}
|
|
44
|
+
if (!isPosNum(p.minRewardRisk) || p.minRewardRisk < 1) {
|
|
45
|
+
add("skill_capital_sizing_reward_risk", "capitalSizing.minRewardRisk must be a finite number >= 1");
|
|
46
|
+
}
|
|
47
|
+
if (isPosNum(p.perTicketCapitalPct) &&
|
|
48
|
+
isPosNum(p.totalCapitalPct) &&
|
|
49
|
+
p.perTicketCapitalPct > p.totalCapitalPct) {
|
|
50
|
+
add("skill_capital_sizing_ticket_cap", "capitalSizing.perTicketCapitalPct cannot exceed totalCapitalPct");
|
|
51
|
+
}
|
|
52
|
+
if (isPosNum(p.totalCapitalPct) &&
|
|
53
|
+
isNonNegNum(p.cashReservePct) &&
|
|
54
|
+
p.totalCapitalPct + p.cashReservePct > 100) {
|
|
55
|
+
add("skill_capital_sizing_total_reserve", "capitalSizing.totalCapitalPct + cashReservePct cannot exceed 100");
|
|
56
|
+
}
|
|
57
|
+
return issues;
|
|
58
|
+
}
|
|
7
59
|
export function validateSkill(parsed, mode = "self-host") {
|
|
8
60
|
const raw = parsed.raw;
|
|
9
61
|
const issues = [];
|
|
@@ -61,6 +113,10 @@ export function validateSkill(parsed, mode = "self-host") {
|
|
|
61
113
|
r.direction !== "short_only")
|
|
62
114
|
add("skill_risk_direction", 'risk.direction must be "long_only" or "short_only" (omit for both)');
|
|
63
115
|
}
|
|
116
|
+
// Opt-in capital sizing is strict in BOTH hosting modes. The older `sizing`
|
|
117
|
+
// prose block remains soft guidance and never silently activates this policy.
|
|
118
|
+
if (raw.capitalSizing !== undefined)
|
|
119
|
+
issues.push(...validateCapitalSizingPolicy(raw.capitalSizing));
|
|
64
120
|
// Model
|
|
65
121
|
if (raw.model === undefined) {
|
|
66
122
|
if (mode === "self-host")
|
package/dist/agent/state.js
CHANGED
|
@@ -11,6 +11,7 @@ export function newState(runId) {
|
|
|
11
11
|
runId,
|
|
12
12
|
cyclesRun: 0,
|
|
13
13
|
writesToday: 0,
|
|
14
|
+
riskIncreasesToday: 0,
|
|
14
15
|
realizedPnlMusd: 0,
|
|
15
16
|
peakRealizedMusd: 0,
|
|
16
17
|
consecutiveRejectCycles: 0,
|
|
@@ -41,6 +42,10 @@ export function loadState(file, runId) {
|
|
|
41
42
|
return rollDay({
|
|
42
43
|
...base,
|
|
43
44
|
...parsed,
|
|
45
|
+
// Pre-field state counted every write. Conservatively carry that count
|
|
46
|
+
// into the entry budget until the next UTC rollover rather than silently
|
|
47
|
+
// granting additional entries on deployment.
|
|
48
|
+
riskIncreasesToday: parsed.riskIncreasesToday ?? parsed.writesToday ?? 0,
|
|
44
49
|
seen: Array.isArray(parsed.seen) ? parsed.seen : [],
|
|
45
50
|
intentSeq: parsed.intentSeq &&
|
|
46
51
|
typeof parsed.intentSeq === "object" &&
|
|
@@ -62,6 +67,7 @@ export function rollDay(state) {
|
|
|
62
67
|
if (state.dayKey !== today) {
|
|
63
68
|
state.dayKey = today;
|
|
64
69
|
state.writesToday = 0;
|
|
70
|
+
state.riskIncreasesToday = 0;
|
|
65
71
|
state.realizedPnlTodayMusd = 0;
|
|
66
72
|
}
|
|
67
73
|
return state;
|
package/dist/agent/strictLint.js
CHANGED
|
@@ -17,6 +17,7 @@ const ALLOWED_KEYS = {
|
|
|
17
17
|
"venues",
|
|
18
18
|
"risk",
|
|
19
19
|
"sizing",
|
|
20
|
+
"capitalSizing",
|
|
20
21
|
"limits",
|
|
21
22
|
"abstention",
|
|
22
23
|
"sync",
|
|
@@ -50,6 +51,15 @@ const ALLOWED_KEYS = {
|
|
|
50
51
|
"direction",
|
|
51
52
|
],
|
|
52
53
|
sizing: null,
|
|
54
|
+
capitalSizing: [
|
|
55
|
+
"version",
|
|
56
|
+
"futuresRiskPct",
|
|
57
|
+
"pmMaxLossPct",
|
|
58
|
+
"perTicketCapitalPct",
|
|
59
|
+
"totalCapitalPct",
|
|
60
|
+
"cashReservePct",
|
|
61
|
+
"minRewardRisk",
|
|
62
|
+
],
|
|
53
63
|
limits: [
|
|
54
64
|
"maxTradesPerDay",
|
|
55
65
|
"maxWritesPerCycle",
|
|
@@ -123,6 +133,7 @@ export function strictLint(raw) {
|
|
|
123
133
|
"triggerPolicy",
|
|
124
134
|
"model",
|
|
125
135
|
"risk",
|
|
136
|
+
"capitalSizing",
|
|
126
137
|
"limits",
|
|
127
138
|
"abstention",
|
|
128
139
|
"sync",
|
|
@@ -133,6 +144,14 @@ export function strictLint(raw) {
|
|
|
133
144
|
lintKeys(block, raw[block], issues);
|
|
134
145
|
}
|
|
135
146
|
// Enum checks.
|
|
147
|
+
const capitalSizing = raw.capitalSizing;
|
|
148
|
+
if (isObj(capitalSizing) && capitalSizing.version !== "equity_fraction_v1") {
|
|
149
|
+
issues.push({
|
|
150
|
+
code: "bad_enum",
|
|
151
|
+
path: "capitalSizing.version",
|
|
152
|
+
message: 'capitalSizing.version must be "equity_fraction_v1"',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
136
155
|
const model = raw.model;
|
|
137
156
|
if (isObj(model) &&
|
|
138
157
|
typeof model.provider === "string" &&
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Observation, OpenPosition, PmPosition, PositionThesis, RunState, Thesis, ThesisInvalidation, ThesisView, Venue } from "./types.js";
|
|
2
|
+
export declare const THESIS_MIN_HOLD_MINUTES = 60;
|
|
3
|
+
export declare const THESIS_MAX_HOLD_MINUTES: number;
|
|
4
|
+
export declare const THESIS_SUMMARY_MAX_CHARS = 200;
|
|
5
|
+
export declare const THESIS_CATALYST_MAX_CHARS = 160;
|
|
6
|
+
export declare const MAX_PERSISTED_THESES = 60;
|
|
7
|
+
export declare function thesisKey(venue: Venue, positionId: number): string;
|
|
8
|
+
export declare function hasInvalidationCondition(inv: ThesisInvalidation): boolean;
|
|
9
|
+
export declare function coerceThesis(raw: unknown): Thesis | undefined;
|
|
10
|
+
export interface BindThesisInput {
|
|
11
|
+
thesis: Thesis;
|
|
12
|
+
venue: "futures" | "pm";
|
|
13
|
+
positionId: number;
|
|
14
|
+
openedAt: string;
|
|
15
|
+
side?: string;
|
|
16
|
+
symbol?: string;
|
|
17
|
+
entryPrice?: number;
|
|
18
|
+
source?: string;
|
|
19
|
+
slug?: string;
|
|
20
|
+
outcomeExternalMarketId?: string;
|
|
21
|
+
entryProbability?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface BoundThesis {
|
|
24
|
+
thesis: PositionThesis;
|
|
25
|
+
notes: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function bindThesis(input: BindThesisInput): BoundThesis;
|
|
28
|
+
export declare function holdMinutesOf(openedAt: string | undefined, fallback: string, nowMs: number): number;
|
|
29
|
+
export declare function evaluateFuturesThesis(t: PositionThesis, pos: OpenPosition, nowMs: number): ThesisView;
|
|
30
|
+
export declare function evaluatePmThesis(t: PositionThesis, pos: PmPosition, nowMs: number): ThesisView;
|
|
31
|
+
export declare function attachTheses(observation: Observation, state: RunState, nowMs: number, opts: {
|
|
32
|
+
prunePm: boolean;
|
|
33
|
+
}): {
|
|
34
|
+
attached: number;
|
|
35
|
+
pruned: string[];
|
|
36
|
+
};
|
|
37
|
+
export declare function thesisExits(observation: Observation): OpenPosition[];
|
|
38
|
+
export declare function rememberThesis(state: RunState, thesis: PositionThesis): void;
|
|
39
|
+
export declare function forgetThesis(state: RunState, key: string): void;
|
|
40
|
+
export declare function describeInvalidation(inv: ThesisInvalidation): string;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Thesis exits (slice 2 of "house agents are not fun", 2026-09-02).
|
|
2
|
+
//
|
|
3
|
+
// A position is opened ON a thesis and leaves when that thesis is INVALIDATED,
|
|
4
|
+
// not only when a stop-loss / take-profit fires or a small adverse tick spooks
|
|
5
|
+
// the model. The model states the thesis inside its open action; the runner
|
|
6
|
+
// sanitizes it side-aware, persists it with the position (RunState.theses,
|
|
7
|
+
// keyed "<venue>:<positionId>") and re-evaluates the machine-checkable parts
|
|
8
|
+
// every cycle: price levels against the live mark, probability levels against
|
|
9
|
+
// the outcome's current market probability, time in trade against the time
|
|
10
|
+
// stop. A futures position whose thesis broke is closed by the runner; PM has
|
|
11
|
+
// no close endpoint, so an invalidated PM thesis is surfaced to the model (do
|
|
12
|
+
// not add; let it settle). The free-text catalyst is never machine-evaluated:
|
|
13
|
+
// the model re-judges it while it manages the position.
|
|
14
|
+
//
|
|
15
|
+
// Pure functions over plain objects; nowMs is injected so every rule is
|
|
16
|
+
// testable without timers. Nothing here widens a cap or touches the kill
|
|
17
|
+
// switch: a thesis exit is a risk-REDUCING close, and the runner executes it
|
|
18
|
+
// only after the kill-switch and drawdown checks have passed.
|
|
19
|
+
// A time stop shorter than an hour on a 3-minute cadence is the churn this
|
|
20
|
+
// slice exists to end; longer than 30 days is not a time stop.
|
|
21
|
+
export const THESIS_MIN_HOLD_MINUTES = 60;
|
|
22
|
+
export const THESIS_MAX_HOLD_MINUTES = 60 * 24 * 30;
|
|
23
|
+
export const THESIS_SUMMARY_MAX_CHARS = 200;
|
|
24
|
+
export const THESIS_CATALYST_MAX_CHARS = 160;
|
|
25
|
+
// Bound the persisted map so a runaway fleet member cannot grow the state JSON.
|
|
26
|
+
export const MAX_PERSISTED_THESES = 60;
|
|
27
|
+
const LEVEL_KEYS = [
|
|
28
|
+
"priceBelow",
|
|
29
|
+
"priceAbove",
|
|
30
|
+
"probabilityBelow",
|
|
31
|
+
"probabilityAbove",
|
|
32
|
+
"maxHoldMinutes",
|
|
33
|
+
];
|
|
34
|
+
export function thesisKey(venue, positionId) {
|
|
35
|
+
return `${venue}:${positionId}`;
|
|
36
|
+
}
|
|
37
|
+
// A finite positive number, tolerating the stringified numbers small models
|
|
38
|
+
// emit ("64000"). Anything else is absent.
|
|
39
|
+
function finitePositive(v) {
|
|
40
|
+
const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
|
|
41
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : undefined;
|
|
42
|
+
}
|
|
43
|
+
function cleanText(v, max) {
|
|
44
|
+
if (typeof v !== "string")
|
|
45
|
+
return undefined;
|
|
46
|
+
const s = v.replace(/\s+/g, " ").trim();
|
|
47
|
+
return s ? s.slice(0, max) : undefined;
|
|
48
|
+
}
|
|
49
|
+
function readInvalidation(raw) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const k of LEVEL_KEYS) {
|
|
52
|
+
const n = finitePositive(raw[k]);
|
|
53
|
+
if (n != null)
|
|
54
|
+
out[k] = n;
|
|
55
|
+
}
|
|
56
|
+
const catalyst = cleanText(raw.catalyst, THESIS_CATALYST_MAX_CHARS);
|
|
57
|
+
if (catalyst)
|
|
58
|
+
out.catalyst = catalyst;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
export function hasInvalidationCondition(inv) {
|
|
62
|
+
return LEVEL_KEYS.some((k) => inv[k] != null) || !!inv.catalyst;
|
|
63
|
+
}
|
|
64
|
+
// Parse a model-emitted thesis. TOLERANT by design (like forecastProbability):
|
|
65
|
+
// a missing / malformed thesis becomes undefined and never fails the action.
|
|
66
|
+
// Accepts the nested contract {summary, invalidation:{...}} and, for weak
|
|
67
|
+
// models, a flattened {summary, priceBelow, ...}. Numbers may be strings.
|
|
68
|
+
export function coerceThesis(raw) {
|
|
69
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
70
|
+
return undefined;
|
|
71
|
+
const o = raw;
|
|
72
|
+
const nested = o.invalidation &&
|
|
73
|
+
typeof o.invalidation === "object" &&
|
|
74
|
+
!Array.isArray(o.invalidation)
|
|
75
|
+
? o.invalidation
|
|
76
|
+
: undefined;
|
|
77
|
+
const invalidation = readInvalidation(nested ?? o);
|
|
78
|
+
const summary = cleanText(o.summary, THESIS_SUMMARY_MAX_CHARS);
|
|
79
|
+
if (!summary && !hasInvalidationCondition(invalidation))
|
|
80
|
+
return undefined;
|
|
81
|
+
return { summary: summary ?? "(no summary stated)", invalidation };
|
|
82
|
+
}
|
|
83
|
+
// Bind a thesis to a freshly opened position, sanitizing it SIDE-AWARE so a
|
|
84
|
+
// wrong-side level can never fire on the next tick: a rising price is never
|
|
85
|
+
// what invalidates a long, a falling one never invalidates a short; for a YES
|
|
86
|
+
// bet the odds must FALL to break it, for a NO bet they must RISE. Levels on the
|
|
87
|
+
// wrong side of entry, levels for the other venue, and out-of-range values are
|
|
88
|
+
// dropped (never silently re-signed); the time stop is clamped to the floor /
|
|
89
|
+
// ceiling. Every drop or clamp is reported in `notes`.
|
|
90
|
+
export function bindThesis(input) {
|
|
91
|
+
const notes = [];
|
|
92
|
+
const inv = { ...input.thesis.invalidation };
|
|
93
|
+
const side = (input.side ?? "").toLowerCase();
|
|
94
|
+
const drop = (k, why) => {
|
|
95
|
+
if (inv[k] == null)
|
|
96
|
+
return;
|
|
97
|
+
delete inv[k];
|
|
98
|
+
notes.push(`dropped ${k}: ${why}`);
|
|
99
|
+
};
|
|
100
|
+
if (input.venue === "futures") {
|
|
101
|
+
drop("probabilityBelow", "not a coin condition");
|
|
102
|
+
drop("probabilityAbove", "not a coin condition");
|
|
103
|
+
if (side === "short") {
|
|
104
|
+
drop("priceBelow", "a falling price never invalidates a short");
|
|
105
|
+
if (inv.priceAbove != null &&
|
|
106
|
+
input.entryPrice != null &&
|
|
107
|
+
inv.priceAbove <= input.entryPrice) {
|
|
108
|
+
drop("priceAbove", `must be above entry ${input.entryPrice}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
drop("priceAbove", "a rising price never invalidates a long");
|
|
113
|
+
if (inv.priceBelow != null &&
|
|
114
|
+
input.entryPrice != null &&
|
|
115
|
+
inv.priceBelow >= input.entryPrice) {
|
|
116
|
+
drop("priceBelow", `must be below entry ${input.entryPrice}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
drop("priceBelow", "not a prediction-market condition");
|
|
122
|
+
drop("priceAbove", "not a prediction-market condition");
|
|
123
|
+
for (const k of ["probabilityBelow", "probabilityAbove"]) {
|
|
124
|
+
if (inv[k] != null && inv[k] > 100)
|
|
125
|
+
drop(k, "0..100 points");
|
|
126
|
+
}
|
|
127
|
+
if (side === "no") {
|
|
128
|
+
drop("probabilityBelow", "falling odds never invalidate a NO");
|
|
129
|
+
if (inv.probabilityAbove != null &&
|
|
130
|
+
input.entryProbability != null &&
|
|
131
|
+
inv.probabilityAbove <= input.entryProbability) {
|
|
132
|
+
drop("probabilityAbove", `must be above entry ${input.entryProbability}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
drop("probabilityAbove", "rising odds never invalidate a YES");
|
|
137
|
+
if (inv.probabilityBelow != null &&
|
|
138
|
+
input.entryProbability != null &&
|
|
139
|
+
inv.probabilityBelow >= input.entryProbability) {
|
|
140
|
+
drop("probabilityBelow", `must be below entry ${input.entryProbability}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (inv.maxHoldMinutes != null) {
|
|
145
|
+
const raw = inv.maxHoldMinutes;
|
|
146
|
+
const clamped = Math.min(THESIS_MAX_HOLD_MINUTES, Math.max(THESIS_MIN_HOLD_MINUTES, Math.round(raw)));
|
|
147
|
+
if (clamped !== raw) {
|
|
148
|
+
notes.push(`maxHoldMinutes ${raw} clamped to ${clamped}`);
|
|
149
|
+
inv.maxHoldMinutes = clamped;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const thesis = {
|
|
153
|
+
summary: input.thesis.summary,
|
|
154
|
+
invalidation: inv,
|
|
155
|
+
venue: input.venue,
|
|
156
|
+
positionId: input.positionId,
|
|
157
|
+
openedAt: input.openedAt,
|
|
158
|
+
};
|
|
159
|
+
if (input.symbol)
|
|
160
|
+
thesis.symbol = input.symbol;
|
|
161
|
+
if (side)
|
|
162
|
+
thesis.side = side;
|
|
163
|
+
if (input.source)
|
|
164
|
+
thesis.source = input.source;
|
|
165
|
+
if (input.slug)
|
|
166
|
+
thesis.slug = input.slug;
|
|
167
|
+
if (input.outcomeExternalMarketId)
|
|
168
|
+
thesis.outcomeExternalMarketId = input.outcomeExternalMarketId;
|
|
169
|
+
if (input.entryPrice != null)
|
|
170
|
+
thesis.entryPrice = input.entryPrice;
|
|
171
|
+
if (input.entryProbability != null)
|
|
172
|
+
thesis.entryProbability = input.entryProbability;
|
|
173
|
+
return { thesis, notes };
|
|
174
|
+
}
|
|
175
|
+
// Whole minutes in the trade, from the position's own openedAt (the server's
|
|
176
|
+
// clock) when present, else the openedAt recorded at bind time.
|
|
177
|
+
export function holdMinutesOf(openedAt, fallback, nowMs) {
|
|
178
|
+
const t = Date.parse(openedAt ?? fallback);
|
|
179
|
+
if (!Number.isFinite(t))
|
|
180
|
+
return 0;
|
|
181
|
+
return Math.max(0, Math.round((nowMs - t) / 60_000));
|
|
182
|
+
}
|
|
183
|
+
function view(t, holdMinutes, invalidatedBy) {
|
|
184
|
+
return {
|
|
185
|
+
summary: t.summary,
|
|
186
|
+
invalidation: t.invalidation,
|
|
187
|
+
holdMinutes,
|
|
188
|
+
status: invalidatedBy ? "invalidated" : "intact",
|
|
189
|
+
...(invalidatedBy ? { invalidatedBy } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// Evaluate a futures thesis against the live position for THIS cycle. Price
|
|
193
|
+
// levels are checked against the mark (at-or-beyond), then the time stop. A
|
|
194
|
+
// position with no mark this cycle is judged on the time stop only.
|
|
195
|
+
export function evaluateFuturesThesis(t, pos, nowMs) {
|
|
196
|
+
const holdMinutes = holdMinutesOf(pos.openedAt, t.openedAt, nowMs);
|
|
197
|
+
const inv = t.invalidation;
|
|
198
|
+
const mark = pos.markPrice;
|
|
199
|
+
let invalidatedBy;
|
|
200
|
+
if (typeof mark === "number" && Number.isFinite(mark)) {
|
|
201
|
+
if (inv.priceBelow != null && mark <= inv.priceBelow) {
|
|
202
|
+
invalidatedBy = `mark ${mark} at or below priceBelow ${inv.priceBelow}`;
|
|
203
|
+
}
|
|
204
|
+
else if (inv.priceAbove != null && mark >= inv.priceAbove) {
|
|
205
|
+
invalidatedBy = `mark ${mark} at or above priceAbove ${inv.priceAbove}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!invalidatedBy &&
|
|
209
|
+
inv.maxHoldMinutes != null &&
|
|
210
|
+
holdMinutes >= inv.maxHoldMinutes) {
|
|
211
|
+
invalidatedBy = `held ${holdMinutes}m, time stop ${inv.maxHoldMinutes}m`;
|
|
212
|
+
}
|
|
213
|
+
return view(t, holdMinutes, invalidatedBy);
|
|
214
|
+
}
|
|
215
|
+
// Evaluate a prediction-market thesis against the outcome's CURRENT market
|
|
216
|
+
// probability (0..100 points, present only while the position is open).
|
|
217
|
+
export function evaluatePmThesis(t, pos, nowMs) {
|
|
218
|
+
const holdMinutes = holdMinutesOf(pos.openedAt, t.openedAt, nowMs);
|
|
219
|
+
const inv = t.invalidation;
|
|
220
|
+
const cur = pos.currentProbability;
|
|
221
|
+
let invalidatedBy;
|
|
222
|
+
if (typeof cur === "number" && Number.isFinite(cur)) {
|
|
223
|
+
if (inv.probabilityBelow != null && cur <= inv.probabilityBelow) {
|
|
224
|
+
invalidatedBy = `probability ${cur} at or below probabilityBelow ${inv.probabilityBelow}`;
|
|
225
|
+
}
|
|
226
|
+
else if (inv.probabilityAbove != null && cur >= inv.probabilityAbove) {
|
|
227
|
+
invalidatedBy = `probability ${cur} at or above probabilityAbove ${inv.probabilityAbove}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!invalidatedBy &&
|
|
231
|
+
inv.maxHoldMinutes != null &&
|
|
232
|
+
holdMinutes >= inv.maxHoldMinutes) {
|
|
233
|
+
invalidatedBy = `held ${holdMinutes}m, time stop ${inv.maxHoldMinutes}m`;
|
|
234
|
+
}
|
|
235
|
+
return view(t, holdMinutes, invalidatedBy);
|
|
236
|
+
}
|
|
237
|
+
// Attach the evaluated thesis to every open position that has one and prune
|
|
238
|
+
// theses whose position is gone (closed, stopped, liquidated, settled). Futures
|
|
239
|
+
// positions are a required read, so a missing futures position is truly gone;
|
|
240
|
+
// PM theses are pruned only when the caller actually read the pm book.
|
|
241
|
+
export function attachTheses(observation, state, nowMs, opts) {
|
|
242
|
+
const theses = state.theses;
|
|
243
|
+
if (!theses)
|
|
244
|
+
return { attached: 0, pruned: [] };
|
|
245
|
+
const live = new Set();
|
|
246
|
+
let attached = 0;
|
|
247
|
+
for (const pos of observation.openPositions) {
|
|
248
|
+
if (pos.venue !== "futures")
|
|
249
|
+
continue;
|
|
250
|
+
const key = thesisKey("futures", pos.id);
|
|
251
|
+
live.add(key);
|
|
252
|
+
const t = theses[key];
|
|
253
|
+
if (!t)
|
|
254
|
+
continue;
|
|
255
|
+
pos.thesis = evaluateFuturesThesis(t, pos, nowMs);
|
|
256
|
+
attached += 1;
|
|
257
|
+
}
|
|
258
|
+
for (const pos of observation.pmPositions ?? []) {
|
|
259
|
+
const key = thesisKey("pm", pos.id);
|
|
260
|
+
live.add(key);
|
|
261
|
+
const t = theses[key];
|
|
262
|
+
if (!t)
|
|
263
|
+
continue;
|
|
264
|
+
pos.thesis = evaluatePmThesis(t, pos, nowMs);
|
|
265
|
+
attached += 1;
|
|
266
|
+
}
|
|
267
|
+
const pruned = [];
|
|
268
|
+
for (const key of Object.keys(theses)) {
|
|
269
|
+
if (live.has(key))
|
|
270
|
+
continue;
|
|
271
|
+
const t = theses[key];
|
|
272
|
+
if (t.venue === "pm" && !opts.prunePm)
|
|
273
|
+
continue;
|
|
274
|
+
if (t.venue === "spot")
|
|
275
|
+
continue; // never bound today; defensive
|
|
276
|
+
delete theses[key];
|
|
277
|
+
pruned.push(key);
|
|
278
|
+
}
|
|
279
|
+
return { attached, pruned };
|
|
280
|
+
}
|
|
281
|
+
// The futures positions the runner must close this cycle: thesis invalidated.
|
|
282
|
+
export function thesisExits(observation) {
|
|
283
|
+
return observation.openPositions.filter((p) => p.venue === "futures" && p.thesis?.status === "invalidated");
|
|
284
|
+
}
|
|
285
|
+
export function rememberThesis(state, thesis) {
|
|
286
|
+
const theses = { ...(state.theses ?? {}) };
|
|
287
|
+
theses[thesisKey(thesis.venue, thesis.positionId)] = thesis;
|
|
288
|
+
const keys = Object.keys(theses);
|
|
289
|
+
if (keys.length > MAX_PERSISTED_THESES) {
|
|
290
|
+
keys.sort((a, b) => Date.parse(theses[a].openedAt) - Date.parse(theses[b].openedAt));
|
|
291
|
+
for (const k of keys.slice(0, keys.length - MAX_PERSISTED_THESES))
|
|
292
|
+
delete theses[k];
|
|
293
|
+
}
|
|
294
|
+
state.theses = theses;
|
|
295
|
+
}
|
|
296
|
+
export function forgetThesis(state, key) {
|
|
297
|
+
if (!state.theses || !(key in state.theses))
|
|
298
|
+
return;
|
|
299
|
+
const theses = { ...state.theses };
|
|
300
|
+
delete theses[key];
|
|
301
|
+
state.theses = theses;
|
|
302
|
+
}
|
|
303
|
+
// One-line rendering for logs and the journal.
|
|
304
|
+
export function describeInvalidation(inv) {
|
|
305
|
+
const parts = [];
|
|
306
|
+
if (inv.priceBelow != null)
|
|
307
|
+
parts.push(`priceBelow ${inv.priceBelow}`);
|
|
308
|
+
if (inv.priceAbove != null)
|
|
309
|
+
parts.push(`priceAbove ${inv.priceAbove}`);
|
|
310
|
+
if (inv.probabilityBelow != null)
|
|
311
|
+
parts.push(`probabilityBelow ${inv.probabilityBelow}`);
|
|
312
|
+
if (inv.probabilityAbove != null)
|
|
313
|
+
parts.push(`probabilityAbove ${inv.probabilityAbove}`);
|
|
314
|
+
if (inv.maxHoldMinutes != null)
|
|
315
|
+
parts.push(`time stop ${inv.maxHoldMinutes}m`);
|
|
316
|
+
if (inv.catalyst)
|
|
317
|
+
parts.push(`catalyst: ${inv.catalyst}`);
|
|
318
|
+
return parts.length > 0 ? parts.join(", ") : "no condition";
|
|
319
|
+
}
|